Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ be distributed under **AGPL-3.0**, with the following scope:

A copy of the exchange is retained by the project author.

## Third-party code bundled in this repository

Vendored under `map/static/map/vendor/` and shipped as-is, each under its own
license:

- **Leaflet** (`vendor/leaflet.js`, `vendor/leaflet.css`) -- BSD-2-Clause,
(c) Volodymyr Agafonkin / CloudMade. <https://leafletjs.com/>
- **posthog-js** 1.427.2 (`vendor/posthog.js`, the upstream
`dist/array.no-external.js` build) -- MIT and Apache-2.0, (c) PostHog Inc.
<https://github.com/PostHog/posthog-js>. Loaded only by the hosted site;
see `analytics.js` for the gate and for what is sent.

## Not licensed by this repository

- The **Satisfactory Save Map** name, logo, and the `satisfactorymap.net`
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ up to **15 seconds at a time** — WebGL rendering instead of DOM markers
over HTTP). The file downloads directly into your browser — never through
this site's servers — so its host must allow cross-origin (CORS) requests.
- **Private by construction** — fully client-side; the save never leaves
your machine. Works offline once loaded.
your machine. Works offline once loaded. satisfactorymap.net counts
anonymous, cookieless usage (page views, and how long a parse took) via
PostHog's EU region; nothing about the save itself is sent, and the desktop
app and any local build send nothing at all. See `map/static/map/analytics.js`
— it is short, and it is the whole of it.

![Factory detail: production rows, belts and a rail roundabout](docs/screenshot_detail.png)

Expand Down
211 changes: 211 additions & 0 deletions map/static/map/analytics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/* Product analytics for the hosted site (PostHog).
*
* Deliberately small: is the site used, do saves parse, and which features do
* people actually open. Nothing about the save itself is sent -- no session
* name, no file name, no coordinates, no item names -- only shape and timing
* numbers, because the whole promise of this app is that your save never
* leaves your machine and analytics must not quietly walk that back.
*/
var Analytics = (function() {
"use strict";

// Public (write-only) project key. It is meant to be readable in client
// code -- it can send events and nothing else. Empty disables analytics
// entirely, which is the state a fork or a self-hosted copy inherits.
var PROJECT_KEY = "phc_yiJoNLSnBrq7BApC5VfDmzeB7H6oXui49QsQUEHgBM8W";
var API_HOST = "https://eu.i.posthog.com";

// Build-version query of this script's own URL, so the vendored library is
// cache-busted by a rebuild exactly like every tag build_site.py stamps.
// (Same idiom as save_client.js; the injected tag is not in index.html, so
// stampAssetVersion never sees it.) Empty when serving unstamped sources.
var ASSET_QUERY = (function() {
try {
var src = document.currentScript && document.currentScript.src;
return src ? new URL(src).search : "";
} catch (e) {
return "";
}
})();

var loaded = false;
// Events fired before the library finishes loading. Bounded because a
// failed load must not grow this without limit for the whole session.
var pending = [];
var PENDING_MAX = 32;

// The desktop app bundles this very dist/ (tauri.conf.json frontendDist),
// so "hosted site only" is a runtime question, not a build one: there is no
// separate web build to put the snippet in. The desktop CSP would block the
// request anyway -- gating here is what keeps the app genuinely
// phone-home-free rather than merely failing to phone home.
function enabled() {
if (!PROJECT_KEY) {
return false;
}
if (window.__TAURI__) {
return false;
}
// Local dev and file:// runs would otherwise land in the same project as
// real traffic and skew every number in it.
var host = location.hostname;
return !!host && host !== "localhost" && host !== "127.0.0.1" && host !== "[::1]";
}

var ENABLED = enabled();

function flush() {
for (var i = 0; i < pending.length; i++) {
try {
window.posthog.capture(pending[i][0], pending[i][1]);
} catch (e) { /* analytics must never break the app */ }
}
pending = [];
}

function start() {
if (!ENABLED) {
return;
}
var script = document.createElement("script");
// Vendored (see vendor/posthog.js): the site ships COEP require-corp for
// wasm, under which a plain cross-origin <script> from PostHog's CDN is a
// no-cors request and gets blocked outright -- and the CDN sends no
// Cross-Origin-Resource-Policy. Serving it same-origin sidesteps that,
// and the "no-external" build never injects further script tags, so the
// only cross-origin traffic left is the CORS-mode ingest request.
script.src = "vendor/posthog.js" + ASSET_QUERY;
script.async = true;
script.onload = function() {
if (!window.posthog || !window.posthog.init) {
return;
}
try {
window.posthog.init(PROJECT_KEY, {
api_host: API_HOST,
// Cookieless: no cookie and no localStorage entry, so the site
// needs no consent banner. The cost is that every reload counts as
// a new anonymous user -- read the totals as visits, not people.
persistence: "memory",
person_profiles: "identified_only",
respect_dnt: true,
// Every event this app sends is written by hand below. Autocapture
// on a canvas UI would mostly record "clicked the map".
autocapture: false,
capture_pageview: true,
capture_pageleave: false,
disable_session_recording: true,
disable_surveys: true,
disable_external_dependency_loading: true,
advanced_disable_feature_flags: true,
sanitize_properties: stripQueryStrings
});
loaded = true;
flush();
} catch (e) { /* analytics must never break the app */ }
};
document.head.appendChild(script);
}

// PostHog attaches the page URL to every event, and this app takes a save
// to load as ?url=<remote .sav> (data.js) -- which would quietly ship a
// user's save location to analytics. Drop the query and fragment from every
// URL-ish property instead of trusting the default set to stay harmless.
function stripQueryStrings(props) {
for (var key in props) {
if (!Object.prototype.hasOwnProperty.call(props, key)) {
continue;
}
var isUrlish = key.indexOf("url") !== -1 || key.indexOf("referrer") !== -1;
if (isUrlish && typeof props[key] === "string") {
props[key] = props[key].split("?")[0].split("#")[0];
}
}
return props;
}

function capture(name, props) {
if (!ENABLED) {
return;
}
if (!loaded) {
if (pending.length < PENDING_MAX) {
pending.push([name, props || {}]);
}
return;
}
try {
window.posthog.capture(name, props || {});
} catch (e) { /* analytics must never break the app */ }
}

// Top-bar and toolbar entry points worth knowing the usage of. An explicit
// list, not a blanket click handler: anything not named here is not sent,
// which is a property that survives future markup changes.
var FEATURES = {
depotIconButton: "depot",
mamIconButton: "mam",
altRecipesIconButton: "alt_recipes",
shopIconButton: "shop",
hubIconButton: "hub",
spaceElevatorIconButton: "space_elevator",
githubLink: "github",
downloadSaveBtn: "save_download",
networkComputeBtn: "network_compute",
selectionCopyBtn: "selection_copy",
selectionMoveBtn: "selection_move",
selectionOffsetBtn: "selection_offset",
selectionDeleteBtn: "selection_delete"
};

function onClick(e) {
var el = e.target && e.target.closest && e.target.closest("[id]");
while (el) {
if (FEATURES[el.id]) {
capture("feature_used", { feature: FEATURES[el.id] });
return;
}
el = el.parentElement && el.parentElement.closest("[id]");
}
}

// ---- Public API -----------------------------------------------------------

// Fired once per successful parse. `objects` is counted off the built
// buckets rather than the payload so it means the same thing on every load
// path, and the timing is the whole user-visible wait, not just the parser.
function saveLoaded(source, bytes, ms) {
if (!ENABLED) {
return; // Don't walk the buckets for an event nobody will send.
}
var objects = 0;
try {
var buckets = MapApp.layer && MapApp.layer.buckets;
for (var i = 0; buckets && i < buckets.length; i++) {
objects += (buckets[i].points.length / 2) | 0;
}
} catch (e) { /* count is a nice-to-have, the event is not */ }
capture("save_loaded", {
source: source,
size_mb: bytes ? Math.round(bytes / 1e5) / 10 : null,
objects: objects,
ms: ms ? Math.round(ms) : null
});
}

function toolOpened(id) {
capture("tool_opened", { tool: id || "unknown" });
}

if (ENABLED) {
document.addEventListener("click", onClick, true);
start();
}

return {
capture: capture,
saveLoaded: saveLoaded,
toolOpened: toolOpened,
isEnabled: function() { return ENABLED; }
};
})();
8 changes: 8 additions & 0 deletions map/static/map/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,9 @@
uploadDropText.textContent = "Loading " + file.name + "…";
var pinnedSelection = Tooltip.getPinnedSelection();
showProgress("Reading file", 0);
// Measured across the whole visible wait (read + parse + build), which is
// what a user would call "how long it took".
var startedAt = performance.now();

return file.arrayBuffer()
.then(function(buffer) {
Expand All @@ -271,6 +274,7 @@
if (pinnedSelection) {
restorePinnedSelection(pinnedSelection);
}
Analytics.saveLoaded("file", file.size, performance.now() - startedAt);
setStatus("Loaded: " + payload.sessionName + " (" + payload.saveDatetime + ")");
})
.catch(function(error) {
Expand Down Expand Up @@ -389,9 +393,12 @@
uploadDropText.textContent = "Downloading " + name + "…";
var pinnedSelection = Tooltip.getPinnedSelection();
showProgress("Downloading", 0);
var startedAt = performance.now();
var bytes = 0;

return downloadSave(url)
.then(function(buffer) {
bytes = buffer.byteLength;
return SaveClient.loadSave(buffer, function(phase, current, total) {
var percent = total > 0 ? (current / total) * 100 : 0;
showProgress(phase, percent);
Expand All @@ -410,6 +417,7 @@
if (pinnedSelection) {
restorePinnedSelection(pinnedSelection);
}
Analytics.saveLoaded("url", bytes, performance.now() - startedAt);
setStatus("Loaded: " + payload.sessionName + " (" + payload.saveDatetime + ")");
})
.catch(function(error) {
Expand Down
1 change: 1 addition & 0 deletions map/static/map/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,7 @@

<script src="vendor/leaflet.js"></script>
<!-- ui.js first: every feature script below builds its chrome from it. -->
<script src="analytics.js"></script>
<script src="ui.js"></script>
<script src="map.js"></script>
<script src="webgl_layer.js"></script>
Expand Down
1 change: 1 addition & 0 deletions map/static/map/panels.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@
el.style.display = "";
currentTool = el;
body.classList.add("tool-open");
Analytics.toolOpened(el.id);
};

Panels.closeTool = function(el) {
Expand Down
1 change: 1 addition & 0 deletions map/static/map/vendor/posthog.js

Large diffs are not rendered by default.

Loading