32cd2100d9dd11140705c848850e86c4f79191c4 max Tue Sep 1 07:02:04 2026 -0700 Reusable "view snapshot" sessions for durable, minimal Share-a-link links "Share a link" could only share the whole cart: it saved a session holding every track, setting and position, which bloated the central db and leaked the sharer's unrelated tracks to whoever opened the link. Anonymous share sessions (under the reserved user "l") were also never reaped, so they accumulated forever, and their 8-char names were generated client-side with no uniqueness check, so two shares could collide and silently overwrite. Adds a lightweight "snapshot session" facility (lib/snapshotSession.c): a snapshot stores only the handful of cart variables a feature declares (a registered snapshotType, e.g. "blat" -> {db, blatLastBigBed}), moves only those variables' trash files into durable sessionData storage, and is saved under a "__"-prefixed name. The "__" marks it machine-made: hidden from the My Sessions list by default and eligible for reaping. For the anonymous "l" owner the durable files fan out over two extra hash levels so one directory never fills with millions of entries. Every anonymous link now shares one server-side name generator (snapshotNewName): a unique (db-checked), crypto-strong, "__"-prefixed token, so tokens never collide. The hgSession doSaveSessionJson endpoint gained hgS_snapshotType (save a minimal snapshot rather than the whole cart) and hgS_doAnonName (reserve a unique anonymous name without saving, so the top-right dialog can preview the exact link before it is created). Wired three callers to the facility: - hgc htcBlatAlign "Share a link": a minimal "blat" snapshot instead of a full-cart anonymous session. - hgBlat results "Share a link": creates a "blat" snapshot on click and reveals its ?u=&s= reopen link (rebuilt from the durable bigPsl by the existing doShareReopen), replacing the trash-only reveal. - top-right "Share a link": anonymous links use the reserved server name; logged-in named shares are unchanged. snapshotReaper (hg/utils) garbage-collects abandoned anonymous snapshots: it deletes user "l" "__" rows whose lastUse is older than the TTL (hg.conf snapshot.ttlDays, default ~4 years) and removes their durable files. lastUse is bumped on every open by the existing session load, so a link stays alive as long as it is used. Meant to run from the trash-cleaner cron. Also folds in the recent Share-dialog work in these files: the auto share name is a short "_" prefix instead of "share_", the dialog previews the link and creates it only when the button is clicked (no orphan session just from opening the dialog), an optional name field with an overwrite warning, and the anonymous save path forces the reap-eligible "__" name. refs #38197 diff --git src/hg/hgSession/hgSession.c src/hg/hgSession/hgSession.c index 56c7a75a81d..bf8e16e50a8 100644 --- src/hg/hgSession/hgSession.c +++ src/hg/hgSession/hgSession.c @@ -28,30 +28,31 @@ #include "ra.h" #include "wikiLink.h" #include "customTrack.h" #include "customFactory.h" #include "udc.h" #include "hgSession.h" #include "hgConfig.h" #include "sessionThumbnail.h" #include "filePath.h" #include "obscure.h" #include "trashDir.h" #include "hubConnect.h" #include "trackHub.h" #include "errCatch.h" #include "sessionData.h" +#include "snapshotSession.h" #include "jsonParse.h" #include "jsonWrite.h" #include "perfTimer.h" char *database = NULL; struct perfTimer *hgSessionTiming = NULL; /* Non-NULL when &measureTiming is set; times the page * and is emitted as hgSessionData.timing for the JS. */ void usage() /* Explain usage and exit. */ { errAbort( "hgSession - Interface with wiki login and do session saving/loading.\n" "usage:\n" " hgSession \n" @@ -1033,77 +1034,151 @@ static void saveSessionJsonResult(struct sqlConnection *conn, char *encUserName, char *encSessionName, char *sessionName) /* Emit {"name": ..., "url": ...} for the "Share a link" AJAX endpoints and disconnect. * sessionName is the human-readable (decoded) name; the client uses it as the rename "old name". */ { struct dyString *dyUrl = dyStringNew(0); addSessionLink(dyUrl, encUserName, encSessionName, FALSE, TRUE); puts("Content-Type:application/json\n"); printf("{\"name\": \"%s\", \"url\": \"%s\"}\n", jsonStringEscape(sessionName), jsonStringEscape(dyUrl->string)); dyStringFree(&dyUrl); hDisconnectCentral(&conn); } +void doAnonNameJson() +/* AJAX endpoint that reserves a fresh, guaranteed-unique anonymous snapshot name and returns it as + * JSON {"name": ...} WITHOUT saving anything. The top-right "Share a link" dialog calls this on open + * so it can show the exact link as a preview before the user commits, while keeping name generation + * server-side (unique, crypto-strong) for every anonymous link. */ +{ +struct sqlConnection *conn = hConnectCentral(); +cartRemove(cart, hgsDoAnonName); +if (!sqlTableExists(conn, namedSessionTable)) + { + saveSessionJsonError(conn, "Required session table does not exist in the central database."); + return; + } +char *name = snapshotNewName(conn, "l"); +puts("Content-Type:application/json\n"); +printf("{\"name\": \"%s\"}\n", jsonStringEscape(name)); +hDisconnectCentral(&conn); +} + void doSaveSessionJson(char *userName) /* AJAX endpoint behind the "Share a link" menu button. Save the current cart as a named session * and print JSON {"name": , "url": }. When the user is not logged * in (or hgsShareAnon is set), save under the reserved anonymous user "l" with a random token - * name. When logged in with no name given, generate a short random name. Saved shared by link so - * the link works for anyone. Reuses saveCartAsSession() and addSessionLink(). */ + * name. When logged in, the caller supplies the name (a typed name, or a random internal + * "_XXXXXXXX" name generated client-side). Saved shared by link so the link works for anyone. + * Reuses saveCartAsSession() and addSessionLink(). */ { struct sqlConnection *conn = hConnectCentral(); if (!sqlTableExists(conn, namedSessionTable)) { saveSessionJsonError(conn, "Required session table does not exist in the central database."); return; } boolean anon = isEmpty(userName) || cgiBoolean(hgsShareAnon); +boolean failIfExists = cgiBoolean(hgsFailIfExists); +// A registered snapshot type (e.g. "blat") means: save a lightweight snapshot holding only that +// feature's declared cart vars, not the whole cart (see lib/snapshotSession.c). +char *snapshotType = cgiOptionalString(hgsSnapshotType); // Read the requested name from the request, not the cart (hgSession's Save form leaves a sticky // value in the cart under this same variable that would otherwise shadow ours). char *sessionName = trimSpaces(cloneString(cgiUsualString(hgsNewSessionName, ""))); /* Keep our control variables out of the saved session contents and the user's own cart. */ cartRemove(cart, hgsDoSaveSessionJson); cartRemove(cart, hgsShareAnon); +cartRemove(cart, hgsFailIfExists); +cartRemove(cart, hgsSnapshotType); cartRemove(cart, hgsNewSessionName); cartRemove(cart, hgsNewSessionShare); +/* Snapshot path: a lightweight session holding only the feature's declared cart vars, under a + * server-generated, guaranteed-unique "__"-prefixed name (share tokens must never collide and + * silently overwrite one another). Handled before the normal full-session logic because its + * naming rules differ. Works for both anonymous ("l") and logged-in owners. */ +if (isNotEmpty(snapshotType)) + { + if (snapshotTypeFind(snapshotType) == NULL) + { + saveSessionJsonError(conn, "Unknown snapshot type."); + return; + } + char *snapUser = anon ? "l" : cgiEncodeFull(userName); + char *snapName; + if (isEmpty(sessionName)) + snapName = snapshotNewName(conn, snapUser); /* server-generated, unique */ + else if (startsWith(snapshotNamePrefix, sessionName)) + snapName = cgiEncodeFull(sessionName); + else + snapName = catTwoStrings(snapshotNamePrefix, cgiEncodeFull(sessionName)); + saveSnapshotSession(conn, snapshotType, snapUser, snapName, cart); + char *snapDecoded = cgiDecodeClone(snapName); + saveSessionJsonResult(conn, snapUser, snapName, snapDecoded); + return; + } + char *encUserName = NULL; char *encSessionName = NULL; if (anon) { encUserName = "l"; /* reserved anonymous user -> short link /s/l/ */ - sessionName = makeRandomKey(96); /* 16 URL-safe alphanumeric chars; no encoding needed */ - encSessionName = sessionName; + /* Every anonymous share uses the shared snapshot naming: a server-generated, guaranteed-unique + * "__"-token, so tokens never collide/overwrite and the reaper can garbage-collect abandoned + * ones. The top-right Share dialog passes a name it just reserved (for its live preview); we + * force the "__" prefix either way so the link stays reap-eligible. */ + if (isEmpty(sessionName)) + encSessionName = snapshotNewName(conn, encUserName); + else if (startsWith(snapshotNamePrefix, sessionName)) + encSessionName = cgiEncodeFull(sessionName); + else + encSessionName = catTwoStrings(snapshotNamePrefix, cgiEncodeFull(sessionName)); + sessionName = cgiDecodeClone(encSessionName); // keep decoded name in sync for the JSON result } else { + /* Logged-in callers always supply a name: the caller either typed one or generated a random + * internal "_XXXXXXXX" name client-side (sessRandomShareName in hgSession.js, shared by the + * top-right "Share a link" menu in topLinks.js), so we no longer auto-name here. */ if (isEmpty(sessionName)) { - /* One-click share: auto-name the session. "_" is kept verbatim by cgiEncodeFull (unlike - * "-"), so the short link /s// stays clean. */ - char randName[32]; - char *rk = makeRandomKey(48); /* 8 URL-safe alphanumeric chars */ - safef(randName, sizeof randName, "share_%s", rk); - freeMem(rk); - sessionName = cloneString(randName); + saveSessionJsonError(conn, "Please provide a name for this session."); + return; } encUserName = cgiEncodeFull(userName); encSessionName = cgiEncodeFull(sessionName); + /* The Share dialog sets failIfExists when the user typed a custom name, so it can warn before + * clobbering an existing session of theirs. Report the clash instead of overwriting. */ + if (failIfExists) + { + char query[1024]; + sqlSafef(query, sizeof query, + "select count(*) from %s where userName = '%s' and sessionName = '%s'", + namedSessionTable, encUserName, encSessionName); + if (sqlQuickNum(conn, query) > 0) + { + puts("Content-Type:application/json\n"); + printf("{\"exists\": true}\n"); + hDisconnectCentral(&conn); + return; + } + } } saveCartAsSession(conn, encUserName, encSessionName, 1); /* shared by link */ saveSessionJsonResult(conn, encUserName, encSessionName, sessionName); } void doRenameSessionJson(char *userName) /* AJAX endpoint for the "Specify name" step of the Share dialog: rename an existing session * (hgsOldSessionName -> hgsNewSessionName) under the current user. Logged-in only. Rejects a * name already in use rather than overwriting it. Returns {"name","url"} or {"error"}. */ { struct sqlConnection *conn = hConnectCentral(); // Read the names from the request, not the cart: hgSession's Save form also uses these variables // and leaves a sticky value (e.g. the username) in the cart that would otherwise shadow ours. char *oldName = trimSpaces(cloneString(cgiUsualString(hgsOldSessionName, ""))); @@ -2636,30 +2711,34 @@ launchForeAndBackGround("makeDownloadSessionCtData"); exit(0); } } else if (doDownloadList) doDownloadSessionCtData(doDownloadList); else if (cartVarExists(cart, hgsDoMainPage) || cartVarExists(cart, hgsCancel)) doMainPage(userName, NULL); else if (cartVarExists(cart, hgsDoNewSession)) { char *message = doNewSession(userName); doMainPage(userName, message); } +else if (cartVarExists(cart, hgsDoAnonName)) + { + doAnonNameJson(); + } else if (cartVarExists(cart, hgsDoSaveSessionJson)) { doSaveSessionJson(userName); } else if (cartVarExists(cart, hgsDoRenameSessionJson)) { doRenameSessionJson(userName); } else if (cartVarExists(cart, hgsDoDeleteJson)) { doDeleteSessionJson(userName); } else if (cartVarExists(cart, hgsDoShareJson)) { doShareSessionJson(userName);