1c52aaa92828b6ecc18dd5800fe6650b91daf696 max Fri Sep 18 07:22:10 2026 -0700 hgSession: the new Sessions page also lists the sessions saved on our other servers #Preview2 week - bugs introduced now will need a build patch to fix Every geo mirror node keeps its own namedSessionDb, so a session saved on genome-euro is invisible on genome.ucsc.edu and people do not find it again. The table now holds the sessions from every node, with a new Server column saying where each one lives, and the session name links to the server that holds it. Two new hgSession actions, both answered in main() before the cart is built, so that a request from another node leaves no cart, userDb or sessionDb row behind: hgS_doSessionListJson returns this server's sessions for the user named by the login cookie, as JSON. This is what the other nodes call. hgS_doMirrorSessions asks every other gbNode for that list and returns what came back. It sends all of the requests before reading any of the answers, so the wait is the slowest node rather than the sum of them. The fetch goes through our own server rather than from the browser to the other nodes, which keeps cross-origin requests out of it: apache handles the CORS headers differently on every node, and the two mirrors are administered by Bielefeld and RIKEN, while CGI code reaches them with the release. The user's login cookies, and only those, are passed on (wikiLinkLoginCookieHeader), which works because the nodes share a cookie salt. The page never waits for any of this: the table is drawn from this server's own sessions and the rows from the other servers are merged in as they arrive, keeping the current sort, search and page. A node that does not answer, times out, or runs a release without the endpoint is named in a line under the toolbar instead, and costs nothing else. Sessions from another server are shown but not managed here: no Share, Edit, Overwrite or Delete, no bulk-select checkbox, and the Update now shortcut keeps ignoring them. Table rows are keyed by a new uid rather than by the session name, since the same name can now be in the table twice, once per server. geoMirrorThisNode() and geoMirrorOtherNodes() read the node list from the hgcentral gbNode table, so nothing new has to be configured: browser.node already says which node a server is. diff --git src/hg/hgSession/hgSession.c src/hg/hgSession/hgSession.c index 6fb673380c0..86f6b239a04 100644 --- src/hg/hgSession/hgSession.c +++ src/hg/hgSession/hgSession.c @@ -27,30 +27,31 @@ #include "hdb.h" #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 "geoMirror.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" @@ -60,30 +61,31 @@ } /* Global variables. */ struct cart *cart; char *excludeVars[] = {"Submit", "submit", hgsSessionDataDbSuffix, NULL}; /* Javascript to confirm that the user truly wants to delete a session. */ #define confirmDeleteFormat "return confirm('Are you sure you want to delete ' + decodeURIComponent('%s') + '?');" /* Forward declarations for the experimental client-rendered Sessions page (hgSession.js), which is * an opt-in alternative gated by the sessionNewPage / sessionNewPageBanner hg.conf flags, mirroring * hgBlat's blatNewForm / blatNewFormBanner facelift. Defined below. */ static boolean sessionNewPageActive(); static void printSessionNewPageBanner(boolean onNewPage); void doMainPageNew(char *userName, char *message); +static void sessionListToJson(char *userName, struct jsonWrite *jw); /* Gallery thumbnail helpers, defined further below with the rest of the gallery code. The AJAX * endpoints above them have to keep a thumbnail in step with its session, so they need these. */ int thumbnailAdd(char *encUserName, char *encSessionName, struct sqlConnection *conn, struct dyString *dyMessage); void thumbnailRemove(char *encUserName, char *encSessionName, struct sqlConnection *conn); char *cgiDecodeClone(char *encStr) /* Allocate and return a CGI-decoded copy of encStr. */ { size_t len = strlen(encStr); char *decStr = needMem(len+1); cgiDecode(encStr, decStr, len); return decStr; } @@ -2294,30 +2296,48 @@ * (may over/undercount some composite subtracks); good enough for a hint. */ { int n = 0; struct hashEl *list = hashElListHash(cart->hash), *el; for (el = list; el != NULL; el = el->next) { char *v = (char *)el->val; if (v && (sameString(v, "dense") || sameString(v, "squish") || sameString(v, "pack") || sameString(v, "full") || sameString(v, "show"))) n++; } hashElFreeList(&list); return n; } +static void writeMirrorJson(struct jsonWrite *jw, struct slPair *node) +/* Write one gbNode (name=shortLabel, val=domain) into an open object as the label the table and + * the note show, e.g. "genome-euro", the shortLabel as the mouseover, e.g. "European Server", + * and the URL of that node's hgSession. No hgsid on that URL: the cart is per node. The URL + * asks for this same page there rather than the classic one, and carries the anchor that scrolls + * straight to the session table. */ +{ +char *domain = node->val; +char *shortName = cloneString(domain); +char *dot = strchr(shortName, '.'); +if (dot != NULL) + *dot = '\0'; +jsonWriteString(jw, "label", shortName); +jsonWriteString(jw, "title", node->name); +jsonWriteStringf(jw, "url", "https://%s/cgi-bin/hgSession?sessionNewPage=1#sessions", domain); +freez(&shortName); +} + static void sessionDataToJson(char *userName, struct jsonWrite *jw) /* Fill jw (an open object) with { config:{...}, sessions:[...] } for the experimental page. */ { boolean loggedIn = isNotEmpty(userName); boolean loginAvail = (loginSystemEnabled() || wikiLinkEnabled()); jsonWriteObjectStart(jw, "config"); jsonWriteBoolean(jw, "loggedIn", loggedIn); jsonWriteBoolean(jw, "loginAvail", loginAvail); if (loggedIn) jsonWriteString(jw, "userName", userName); jsonWriteString(jw, "hgsid", cartSessionId(cart)); jsonWriteString(jw, "cartVar", cartSessionVarName()); /* Current view being saved, for the save-summary line. Show the assembly accession, not the * internal "hub__" name, for assembly hubs (trackHubSkipHubName). */ @@ -2336,38 +2356,70 @@ if (!loggedIn) jsonWriteString(jw, "loginUrl", wikiLinkUserLoginUrl(cartSessionId(cart))); else { jsonWriteString(jw, "logoutUrl", wikiLinkUserLogoutUrl(cartSessionId(cart))); if (!loginUseBasicAuth()) jsonWriteString(jw, "changePasswordUrl", wikiLinkChangePasswordUrl(cartSessionId(cart))); } jsonWriteString(jw, "signupUrl", wikiLinkUserSignupUrl(cartSessionId(cart))); } jsonWriteStringf(jw, "classicUrl", "hgSession?sessionNewPage=0&%s=%s", cartSessionVarName(), cartSessionId(cart)); jsonWriteString(jw, "helpUrl", "../goldenPath/help/hgSessionHelp.html"); jsonWriteString(jw, "galleryUrl", "../goldenPath/help/sessions.html"); jsonWriteStringf(jw, "publicSessionsUrl", "../cgi-bin/hgPublicSessions?%s", cartSidUrlString(cart)); +/* The geo mirror nodes (hgcentral gbNode). Each node keeps its own namedSessionDb, so the JS + * asks the other nodes for their session lists and merges them into the table, marking each row + * with the server it came from. "mirrors" are the other nodes, "thisServer" is this one. */ +struct slPair *thisNode = geoMirrorThisNode(); +if (thisNode != NULL) + { + jsonWriteObjectStart(jw, "thisServer"); + writeMirrorJson(jw, thisNode); + jsonWriteObjectEnd(jw); + slPairFreeValsAndList(&thisNode); + } +struct slPair *mirrors = geoMirrorOtherNodes(); +if (mirrors != NULL) + { + jsonWriteListStart(jw, "mirrors"); + struct slPair *mirror; + for (mirror = mirrors; mirror != NULL; mirror = mirror->next) + { + jsonWriteObjectStart(jw, NULL); + writeMirrorJson(jw, mirror); + jsonWriteObjectEnd(jw); + } + jsonWriteListEnd(jw); + slPairFreeValsAndList(&mirrors); + } /* Reset-to-defaults link, same as showCartLinks(). */ char returnAddress[512]; safef(returnAddress, sizeof(returnAddress), "%s?%s", hgSessionName(), cartSidUrlString(cart)); jsonWriteStringf(jw, "resetUrl", "../cgi-bin/cartReset?%s&destination=%s", cartSidUrlString(cart), cgiEncodeFull(returnAddress)); jsonWriteObjectEnd(jw); // config perfTimerStep(hgSessionTiming, "page header + config"); +sessionListToJson(userName, jw); +} + +static void sessionListToJson(char *userName, struct jsonWrite *jw) +/* Write the "sessions" list for userName into jw (an open object): one object per saved session. */ +{ +boolean loggedIn = isNotEmpty(userName); jsonWriteListStart(jw, "sessions"); if (loggedIn) { struct sqlConnection *conn = hConnectCentral(); if (sqlTableExists(conn, namedSessionTable)) { char *encUserName = cgiEncodeFull(userName); boolean gotSettings = (sqlFieldIndex(conn, namedSessionTable, "settings") >= 0); char query[512]; if (gotSettings) sqlSafef(query, sizeof(query), "SELECT sessionName, shared, firstUse, useCount, contents, settings, lastUse FROM %s " "WHERE userName = '%s' ORDER BY sessionName;", namedSessionTable, encUserName); else sqlSafef(query, sizeof(query), @@ -2551,30 +2603,187 @@ struct jsonWrite *jw = jsonWriteNew(); jsonWriteObjectStart(jw, NULL); sessionDataToJson(userName, jw); /* When &measureTiming is set, hand the per-phase timings to hgSession.js (it shows them in a * dialog). Emitted at the top level as hgSessionData.timing. */ perfTimerJson(hgSessionTiming, jw, "timing"); jsonWriteObjectEnd(jw); jsInlineF("var hgSessionData = %s;\n", jw->dy->string); jsonWriteFree(&jw); perfTimerFree(&hgSessionTiming); cartWebEnd(); } +/* ---- Cross-mirror session list ---- */ + +#define mirrorReadTimeout 8 /* seconds to wait for a mirror node's answer before giving up */ + +static int mirrorConnect(char *domain, char *cookieHeader) +/* Send a session list request to one mirror node and return the socket to read the answer from, + * or -1. Only sends - the answers are read afterwards, so that the nodes work on their queries + * at the same time instead of one after the other. */ +{ +char url[512]; +safef(url, sizeof(url), "https://%s/cgi-bin/hgSession?%s=1", domain, hgsDoSessionListJson); +int sd = -1; +struct errCatch *errCatch = errCatchNew(); +if (errCatchStart(errCatch)) + { + sd = netHttpConnect(url, "GET", "HTTP/1.0", "hgSession", cookieHeader); + if (sd >= 0) + setReadWriteTimeouts(sd, mirrorReadTimeout); + } +errCatchEnd(errCatch); +if (errCatch->gotError) + sd = -1; +errCatchFree(&errCatch); +return sd; +} + +static struct jsonElement *mirrorRead(int sd, char *domain) +/* Read one mirror node's answer and return its parsed "sessions" list, or NULL if the node did + * not send one. A node running a release without the endpoint answers with an HTML error page, + * so the answer is parsed here and written back out by us rather than passed through. */ +{ +struct jsonElement *sessions = NULL; +char url[512]; +safef(url, sizeof(url), "https://%s/cgi-bin/hgSession", domain); +struct errCatch *errCatch = errCatchNew(); +if (errCatchStart(errCatch)) + { + int redirectSd = -1; + char *redirectUrl = NULL; + if (netSkipHttpHeaderLinesHandlingRedirect(sd, url, &redirectSd, &redirectUrl)) + { + if (redirectSd >= 0) + { + close(sd); + sd = redirectSd; + } + struct dyString *dy = netSlurpFile(sd); + struct jsonElement *parsed = jsonParse(dy->string); /* errAborts if it is not JSON */ + if (parsed != NULL) + sessions = jsonFindNamedField(parsed, "", "sessions"); + dyStringFree(&dy); + } + } +errCatchEnd(errCatch); +if (errCatch->gotError) + sessions = NULL; +errCatchFree(&errCatch); +close(sd); +return sessions; +} + +void doMirrorSessionsJson() +/* AJAX for this server's own hgSession.js: ask every other mirror node for the sessions the + * logged-in user has saved there and hand them back as + * {"mirrors": [{"label":.., "title":.., "url":.., "sessions":[..]}, ..]}. + * A node that does not answer, times out, or runs a release without the session list endpoint is + * reported with "error" instead of "sessions" and the page says so; it never holds up the table, + * which the browser has already drawn from this server's own sessions. + * + * Runs before the cart is created: it needs nothing from the cart, and the fetches take long + * enough that it should not be holding a cart open. The user's login cookies are passed on to + * the other nodes, which share our cookie salt, so that they can recognize the same user. */ +{ +char *userName = (loginSystemEnabled() || wikiLinkEnabled()) ? wikiLinkUserName() : NULL; +char *cookieHeader = wikiLinkLoginCookieHeader(); +struct slPair *nodes = (isNotEmpty(userName) && cookieHeader != NULL) ? + geoMirrorOtherNodes() : NULL; +struct jsonWrite *jw = jsonWriteNew(); +jsonWriteObjectStart(jw, NULL); +jsonWriteListStart(jw, "mirrors"); +int nodeCount = slCount(nodes), i; +if (nodeCount > 0) + { + int *sockets = NULL; + AllocArray(sockets, nodeCount); + struct slPair *node; + /* Send all of the requests before reading any of the answers, so that the nodes work on + * their queries at the same time and this takes as long as the slowest one, not as long as + * all of them together. */ + for (i = 0, node = nodes; node != NULL; i++, node = node->next) + sockets[i] = mirrorConnect(node->val, cookieHeader); + for (i = 0, node = nodes; node != NULL; i++, node = node->next) + { + struct jsonElement *sessions = (sockets[i] >= 0) ? mirrorRead(sockets[i], node->val) : NULL; + jsonWriteObjectStart(jw, NULL); + writeMirrorJson(jw, node); + if (sessions != NULL) + jsonWriteJsonElement(jw, "sessions", sessions); + else + jsonWriteString(jw, "error", "no answer"); + jsonWriteObjectEnd(jw); + } + freez(&sockets); + slPairFreeValsAndList(&nodes); + } +jsonWriteListEnd(jw); +jsonWriteObjectEnd(jw); +printf("Cache-Control: no-store\n"); +cgiPrintContentType("application/json"); +printf("%s\n", jw->dy->string); +jsonWriteFree(&jw); +freez(&cookieHeader); +} + +static void sessionListCorsHeaders() +/* Let one of our other mirror nodes read this response with the user's login cookie attached. + * Only an Origin that matches a gbNode domain gets the header, so no other page can ask the + * browser to hand it someone's session list. The value printed is the one we built ourselves, + * never the raw request header. */ +{ +printf("Vary: Origin\n"); +char *origin = getenv("HTTP_ORIGIN"); +if (isEmpty(origin)) + return; +struct slPair *nodes = geoMirrorOtherNodes(), *node; +for (node = nodes; node != NULL; node = node->next) + { + char allowed[512]; + safef(allowed, sizeof(allowed), "https://%s", (char *)node->val); + if (sameString(origin, allowed)) + { + printf("Access-Control-Allow-Origin: %s\n", allowed); + printf("Access-Control-Allow-Credentials: true\n"); + break; + } + } +slPairFreeValsAndList(&nodes); +} + +void doSessionListJson() +/* AJAX for hgSession.js running on another mirror node: return this server's saved sessions for + * the logged-in user, as {"sessions":[...]}. Answered before the cart is created - a request + * from another node must not leave a cart behind here - so the user is identified from the login + * cookie alone, and an unauthenticated request gets an empty list rather than an error. */ +{ +char *userName = (loginSystemEnabled() || wikiLinkEnabled()) ? wikiLinkUserName() : NULL; +sessionListCorsHeaders(); +printf("Cache-Control: no-store\n"); +cgiPrintContentType("application/json"); +struct jsonWrite *jw = jsonWriteNew(); +jsonWriteObjectStart(jw, NULL); +sessionListToJson(userName, jw); +jsonWriteObjectEnd(jw); +printf("%s\n", jw->dy->string); +jsonWriteFree(&jw); +} + /* ---- JSON action endpoints for the experimental page's inline table actions ---- */ static void saveSessionJsonOk(struct sqlConnection *conn, char *extraFields) /* Emit {"success": true[, ]} and disconnect. extraFields (may be NULL) is inserted * verbatim after "success": true, e.g. ", \"shared\": 2". */ { cgiPrintContentType("application/json"); printf("{\"success\": true%s}\n", extraFields ? extraFields : ""); hDisconnectCentral(&conn); } void doDeleteSessionJson(char *userName) /* AJAX: delete the session named by hgsOldSessionName under the current user. */ { struct sqlConnection *conn = hConnectCentral(); @@ -2927,20 +3136,36 @@ char *message = doUpdateSessions(userName); doMainPage(userName, message); } cleanHgSessionFromCart(cart); /* Save the cart state: */ cartCheckout(&cart); } int main(int argc, char *argv[]) /* Process command line. */ { long enteredMainTime = clock1000(); htmlPushEarlyHandlers(); cgiSpoof(&argc, argv); +/* The session list another mirror node asks for is answered before the cart exists, so that a + * request from a node does not create a cart - and a row in userDb and sessionDb - here. */ +if (cgiOptionalString(hgsDoSessionListJson) != NULL) + { + doSessionListJson(); + cgiExitTime("hgSession", enteredMainTime); + return 0; + } +/* Likewise the fan-out to the other nodes: it needs nothing from the cart, and it waits on + * other servers long enough that it should not be holding one open. */ +if (cgiOptionalString(hgsDoMirrorSessions) != NULL) + { + doMirrorSessionsJson(); + cgiExitTime("hgSession", enteredMainTime); + return 0; + } hgSession(); cgiExitTime("hgSession", enteredMainTime); return 0; }