81d7cff6cb6665feb772b1339b6298273a3c49be max Thu Aug 6 08:24:38 2026 -0700 hgBlat new results page: table redesign, rename modal, share link, and an XSS fix Iterates on the new hgBlat "table" results page (rendered by hg/js/hgBlat.js from JSON emitted by hgBlat.c) per feedback on #37893: - Columns: "Open in Genome Browser" (position link + new-tab icon), "Show" (base alignment), Query coverage moved before Locus, score-scaled bar in the Score column, Identity as plain %, comma-formatted Span. Locus is plain text, clipped with a CSS ellipsis (full value in title). - Tooltips on every action link and every column header (via the existing convertTitleTagsToMouseovers mechanism). - "Rename BLAT Track": a real modal dialog (replaces the old inline toggle form), reusing the existing hgc buildBigPsl call via a new window.blatRenameCt() helper; it no longer depends on a generic page-global. New cfg fields canRename / trackName / trackDescription drive it. - "Share a link": trash-backed stable URL toggle, with a share-nodes icon. - Security: cgiEncode the query name in the htcUserAli detailsUrl (its sibling already did), and htmlEncode every URL before it goes into an href in hgBlat.js, so a crafted query/sequence name can't break out of the attribute (XSS). - Shared htmlEncode() moved into hg/js/utils.js for reuse instead of a per-file escaper. - hg.conf: blatNewPageBanner (invite banner, default off), blatOldTracks (keep/hide/delete previous BLAT tracks at creation) documented in ex.hg.conf; hgc.c tags BLAT tracks with blatResult=on and clears prior ones per blatOldTracks. refs #37893 diff --git src/hg/hgBlat/hgBlat.c src/hg/hgBlat/hgBlat.c index 807d24e78dd..5235a06b1c0 100644 --- src/hg/hgBlat/hgBlat.c +++ src/hg/hgBlat/hgBlat.c @@ -514,35 +514,36 @@ browserHelp, browserUrl, psl->tName, psl->tStart + 1, psl->tEnd, database, pslName, faName, uiState, unhideTrack); printf("new tab%s ", helpText, browserUrl, psl->tName, psl->tStart + 1, psl->tEnd, database, pslName, faName, unhideTrack, icon); } } printf("", hgcUrl, psl->tStart, pslName, cgiEncode(faName), psl->qName, psl->tName, psl->tStart, psl->tEnd, database, uiState); printf("details "); } static char *chromTypeNote(char *tName) -/* Return a short explanation for _alt/_fix/_random/chrUn sequences, or NULL for a normal chrom. */ +/* Return a short explanation for special sequence names (alt/fix/random/hap/unplaced), or NULL for a + * normal chromosome. tName should be the display name the user sees. */ { if (endsWith(tName, "_fix")) return "Assembly fix patch: corrects an error in the reference assembly."; -if (endsWith(tName, "_alt")) +if (endsWith(tName, "_alt") || stringIn("_hap", tName)) return "Alternate haplotype: an alternate sequence for this region."; if (endsWith(tName, "_random")) return "Unlocalized sequence: known chromosome, position not determined."; if (startsWith(tName, "chrUn")) return "Unplaced sequence: chromosome of origin unknown."; return NULL; } static char *blatBrowserUrl(struct psl *psl, char *database, char *browserUrl, char *pslName, char *faName, char *customText, char *uiState, char *unhideTrack, boolean withUiState) /* Return a Genome Browser URL for one BLAT hit. withUiState appends the hgsid; it is included on * the in-tab link but omitted from the new-tab link, matching the classic hyperlink behavior. */ { struct dyString *dy = dyStringNew(256); dyStringPrintf(dy, "%s?position=%s:%d-%d&db=%s", browserUrl, psl->tName, psl->tStart + 1, psl->tEnd, database); @@ -607,91 +608,137 @@ jsonWriteObjectStart(jw, NULL); jsonWriteObjectStart(jw, "config"); /* For assembly/GenArk hubs the internal names carry a "hub_NNN_" prefix; drop it so the Assembly * field reads cleanly (and doesn't show the prefix twice), matching the BLAT Results page title. */ jsonWriteString(jw, "db", trackHubSkipHubName(database)); jsonWriteString(jw, "organism", trackHubSkipHubName(organism)); jsonWriteString(jw, "queryName", pslList->qName); jsonWriteNumber(jw, "querySize", pslList->qSize); jsonWriteNumber(jw, "hitCount", slCount(pslList)); jsonWriteBoolean(jw, "multiQuery", pslListMultiQuery(pslList)); jsonWriteBoolean(jw, "hasLocus", locusConn != NULL); /* Sharing a link only makes sense when a durable bigPsl custom track was made from the results * (autoBigPsl); otherwise there is nothing for the shared session to reopen from. */ jsonWriteBoolean(jw, "canShare", autoBigPsl); +/* Renaming the results custom track needs the bigPsl track + the C blatRenameCt() helper, both only + * present with autoBigPsl. hgBlat.js shows its own "Rename BLAT Track" button + modal in that case, + * pre-filled with the track's current name/description (below) so it needs no page-global. */ +jsonWriteBoolean(jw, "canRename", autoBigPsl); +if (autoBigPsl) + { + char *ctName = NULL, *ctDescription = NULL; + getCustomName(database, cart, pslList, &ctName, &ctDescription); + jsonWriteString(jw, "trackName", ctName); + jsonWriteString(jw, "trackDescription", ctDescription); + } /* The classic "Old BLAT result page" view re-reads the trash .pslx from the current search, so it * is only offered on a fresh search (pslName set), not on a shared-link reopen rebuilt from the * durable custom track (where the trash files may be long gone). */ jsonWriteBoolean(jw, "canOldPage", pslName != NULL); jsonWriteString(jw, "hgsid", cartSessionId(cart)); jsonWriteStringf(jw, "newSearchUrl", "hgBlat?db=%s&%s", database, uiState); +/* Stable, shareable page URL: it reopens straight from the trash .pslx/.fa (no saved session, no + * sessionData - the link works until trash is cleaned). Carry only the random basenames as tokens; + * the reopen reconstructs the trash paths, so no filesystem path or session id is exposed in the URL, + * and the recipient uses their own cart. Present whenever the trash files exist (fresh search or a + * trash reopen), so hgBlat.js can pin it into the address bar with history.replaceState(). */ +if (pslName != NULL && faName != NULL) + { + char *pslId = cloneString(strrchr(pslName, '/') ? strrchr(pslName, '/') + 1 : pslName); + char *faId = cloneString(strrchr(faName, '/') ? strrchr(faName, '/') + 1 : faName); + chopSuffix(pslId); /* drop ".pslx" -> bare token */ + chopSuffix(faId); /* drop ".fa" -> bare token */ + jsonWriteStringf(jw, "shareUrl", "hgBlat?blatNewPage=1&db=%s&blatPslId=%s&blatFaId=%s", + database, pslId, faId); + freeMem(pslId); + freeMem(faId); + } char *posStr = cartOptionalString(cart, "position"); if (posStr != NULL) { jsonWriteString(jw, "backUrl", browserUrl); jsonWriteString(jw, "backPos", posStr); } struct dyString *va = dyStringNew(128); dyStringPrintf(va, "%s?db=%s", browserUrl, database); if (customText) dyStringPrintf(va, "&hgt.customText=%s", customText); else if (!autoBigPsl && pslName != NULL) dyStringPrintf(va, "&ss=%s+%s", pslName, faName); dyStringPrintf(va, "&%s%s", uiState, unhideTrack); jsonWriteString(jw, "viewAllUrl", va->string); dyStringFree(&va); -jsonWriteStringf(jw, "geneUrlBase", "%s?db=%s&%s&position=", browserUrl, database, uiState); +/* The query sequence(s) for the "Show Query Sequence" button. Only available on a fresh search, + * where the uploaded FASTA is still in trash (faName); on a shared-link reopen faName is NULL and + * the button is omitted client-side. */ +if (faName != NULL && fileExists(faName)) + { + struct dnaSeq *qSeqList = faReadAllMixed(faName), *qSeq; + if (qSeqList != NULL) + { + jsonWriteListStart(jw, "querySeqs"); + for (qSeq = qSeqList; qSeq != NULL; qSeq = qSeq->next) + { + jsonWriteObjectStart(jw, NULL); + jsonWriteString(jw, "name", qSeq->name); + jsonWriteString(jw, "seq", qSeq->dna); + jsonWriteObjectEnd(jw); + } + jsonWriteListEnd(jw); + } + dnaSeqFreeList(&qSeqList); + } jsonWriteObjectEnd(jw); // config jsonWriteListStart(jw, "hits"); int rank = 0; for (psl = pslList; psl != NULL; psl = psl->next) { ++rank; double ident = 100.0 - pslCalcMilliBad(psl, TRUE) * 0.1; char *displayChromName = chromAliasGetDisplayChrom(database, cart, psl->tName); char *inTabUrl = blatBrowserUrl(psl, database, browserUrl, pslName, faName, customText, uiState, unhideTrack, TRUE); char *newTabUrl = blatBrowserUrl(psl, database, browserUrl, pslName, faName, customText, uiState, unhideTrack, FALSE); jsonWriteObjectStart(jw, NULL); jsonWriteNumber(jw, "rank", rank); jsonWriteString(jw, "qName", psl->qName); jsonWriteNumber(jw, "score", pslScore(psl)); jsonWriteDouble(jw, "identity", ident); jsonWriteString(jw, "chrom", displayChromName); - char *note = chromTypeNote(psl->tName); + char *note = chromTypeNote(displayChromName); /* note must match the NAME the user sees */ if (note != NULL) jsonWriteString(jw, "chromNote", note); jsonWriteString(jw, "strand", psl->strand); jsonWriteNumber(jw, "tStart", psl->tStart + 1); jsonWriteNumber(jw, "tEnd", psl->tEnd); jsonWriteNumber(jw, "span", psl->tEnd - psl->tStart); jsonWriteNumber(jw, "qStart", psl->qStart + 1); jsonWriteNumber(jw, "qEnd", psl->qEnd); jsonWriteNumber(jw, "qSize", psl->qSize); jsonWriteNumber(jw, "matches", psl->match + psl->repMatch); jsonWriteNumber(jw, "misMatch", psl->misMatch); jsonWriteNumber(jw, "gaps", psl->qNumInsert + psl->tNumInsert); jsonWriteNumber(jw, "blocks", psl->blockCount); jsonWriteString(jw, "browserUrl", inTabUrl); jsonWriteString(jw, "newTabUrl", newTabUrl); if (pslName != NULL) jsonWriteStringf(jw, "detailsUrl", "%s?o=%d&g=htcUserAli&i=%s+%s+%s&c=%s&l=%d&r=%d&db=%s&%s", - hgcUrl, psl->tStart, pslName, cgiEncode(faName), psl->qName, psl->tName, + hgcUrl, psl->tStart, pslName, cgiEncode(faName), cgiEncode(psl->qName), psl->tName, psl->tStart, psl->tEnd, database, uiState); else /* Shared-link reopen: there is no trash .pslx, but the durable bigPsl custom track (now in * this cart) lets hgc's htcBlatAlign rebuild the base alignment from the stored query seq. * chrom/start/qName select the hit; db and the browser window come from the loaded cart. */ jsonWriteStringf(jw, "detailsUrl", "%s?g=htcBlatAlign&db=%s&c=%s&o=%d&i=%s&%s", hgcUrl, database, psl->tName, psl->tStart, cgiEncode(psl->qName), uiState); if (locusConn) { struct sqlResult *sr = hRangeQuery(locusConn, "locusName", psl->tName, psl->tStart, psl->tEnd, NULL, 0); char **row = sqlNextRow(sr); if (row != NULL) { char *raw = row[4]; char *full = subTextString(subList, raw); @@ -724,46 +771,49 @@ freeMem(inTabUrl); freeMem(newTabUrl); } jsonWriteListEnd(jw); // hits jsonWriteObjectEnd(jw); // root printf("
\n"); jsInlineF("var hgBlatData = %s;\n", jw->dy->string); jsonWriteFree(&jw); } static void printNewDisplayBanner(char *uiState) /* On the classic hyperlink results page, offer a one-click switch to the modern Table display. * The link sets the blatNewPage cart variable (so the choice sticks for future searches) and * reopens the current results (blatReopen) in the new format. - * The banner is on by default but can be turned off in hg.conf (blatNewPageBanner=off) to stop - * advertising the new page - without releasing new CGIs - while the display itself stays available - * to users who already opted in or use a direct link. */ + * The banner is OFF by default while the new page is still being tested; set blatNewPageBanner=on + * in hg.conf to advertise the new page - without releasing new CGIs. The new display itself stays + * reachable by users who already opted in or use a direct blatNewPage=1 link. */ { -if (!cfgOptionBooleanDefault("blatNewPageBanner", TRUE)) +if (!cfgOptionBooleanDefault("blatNewPageBanner", FALSE)) return; -printf("These shared BLAT results are no longer available. The custom track that " "stored them has expired or been removed. Please run a new " "BLAT search.
\n"); cartWebEnd(); return; } struct psl *pslList = pslListFromBigPslFile(bbFile); @@ -2631,40 +2690,90 @@ printBlatResultsApp(pslList, database, organism, hgTracksName(), hgcName(), NULL, NULL, NULL, uiState, "", locusConn, subList); cartWebEnd(); } static void doReopenResults(char *database, char *organism) /* Re-render the last search's results for the current session from the trash result files saved * with it (see blatPslFile/blatFaFile), without re-running BLAT. showAliPlaces picks the classic * or new-table format from the blatNewPage cart variable, so this backs both the classic page's * "Try the new display" banner and the table's "Old BLAT result page" link. Those trash files are * only guaranteed for the current session, so if they have been cleaned up, say so rather than * showing a broken page. */ { char *pslFile = cartOptionalString(cart, "blatPslFile"); char *faFile = cartOptionalString(cart, "blatFaFile"); +/* Use the db/organism the saved search ran against, not the cart's current db (which may have + * drifted): the trash PSLs carry that assembly's chrom names, so rendering under any other db + * gives broken position and "View alignment" links (hgc "bad input variables"). */ +char *savedDb = cartOptionalString(cart, "blatDb"); +if (isNotEmpty(savedDb)) + { + database = savedDb; + char *savedOrg = cartOptionalString(cart, "blatOrganism"); + if (isNotEmpty(savedOrg)) + organism = savedOrg; + } cartWebStart(cart, database, "%s (%s) BLAT Results", trackHubSkipHubName(organism), trackHubSkipHubName(database)); if (pslFile == NULL || faFile == NULL || !fileExists(pslFile)) printf("These BLAT results are no longer available. Please run a new " "BLAT search.
\n"); else showAliPlaces(pslFile, faFile, NULL, database, gftDna, gftDna, organism, FALSE); cartWebEnd(); } +static boolean blatSafeTrashId(char *s) +/* TRUE only if s is a bare trash-file basename token (letters, digits, underscore). Because it can + * contain no '/', '.' or '..', a path built from it cannot escape the trash directory. */ +{ +if (isEmpty(s)) + return FALSE; +char *p; +for (p = s; *p != '\0'; ++p) + if (!isalnum((unsigned char)*p) && *p != '_') + return FALSE; +return TRUE; +} + +static void doTrashShareReopen(char *database, char *organism, char *pslId, char *faId) +/* Reopen a shared BLAT results link that carries the trash .pslx/.fa basenames (blatPslId/blatFaId in + * the URL) - no saved session, no sessionData; the link works until trash is cleaned. Reconstruct + * the trash paths from the validated tokens, point the cart's blatPslFile/blatFaFile at them, and + * hand off to doReopenResults (which rebuilds the Table view and handles cleaned-up trash gracefully). + * The recipient uses their own cart, so pin the shared results' db here (their cart may carry a stale + * blatDb from an earlier search of their own). */ +{ +cartSetString(cart, "blatNewPage", "1"); /* a shared table link always shows the table */ +if (!blatSafeTrashId(pslId) || !blatSafeTrashId(faId)) + { + cartWebStart(cart, database, "BLAT Results"); + printf("This shared BLAT link is not valid.
\n"); + cartWebEnd(); + return; + } +char pslPath[1024], faPath[1024]; +safef(pslPath, sizeof pslPath, "%s/hgSs/%s.pslx", trashDir(), pslId); +safef(faPath, sizeof faPath, "%s/hgSs/%s.fa", trashDir(), faId); +cartSetString(cart, "blatPslFile", pslPath); +cartSetString(cart, "blatFaFile", faPath); +cartSetString(cart, "blatDb", database); +cartSetString(cart, "blatOrganism", organism); +doReopenResults(database, organism); +} + void doMiddle(struct cart *theCart) /* Write header and body of html page. */ { char *userSeq; char *db, *organism; boolean clearUserSeq = cgiBoolean("Clear"); allGenomes = cgiVarExists("allGenomes"); cart = theCart; dnaUtilOpen(); /* The former "table" value of the output dropdown is now the blatNewPage toggle; migrate any stale * cart value so the dropdown always shows a valid option. */ if (sameOk(cartOptionalString(cart, "output"), "table")) @@ -2691,30 +2800,37 @@ orgChange = sameOk(cgiOptionalString("changeInfo"),"orgChange"); if (orgChange) cgiVarSet("db", hDefaultDbForGenome(cgiOptionalString("org"))); getDbAndGenome(cart, &db, &organism, oldVars); chromAliasSetup(db); /* A shared "?u=&s=" link rebuilds the Table view from the session's durable custom track; it never * re-runs BLAT, so short-circuit the normal query-driven flow here (before findClosestServer, which * is only needed for an actual search). */ if (cgiVarExists("s")) { doShareReopen(db, organism); return; } +/* Stable shareable-link reopen (blatPslId/blatFaId): rebuild the Table straight from the trash + * .pslx/.fa named in the URL - no session, no sessionData. See doTrashShareReopen. */ +if (cgiOptionalString("blatPslId") != NULL && cgiOptionalString("blatFaId") != NULL) + { + doTrashShareReopen(db, organism, cgiString("blatPslId"), cgiString("blatFaId")); + return; + } /* The classic page's "Try the new display" banner and the Table view's "Old BLAT result page" link * both flip the blatNewPage preference and re-render this session's saved results in the other * format, without re-running BLAT. */ if (cgiVarExists("blatReopen")) { doReopenResults(db, organism); return; } char *oldDb = cloneString(db); // n.b. this changes to default db if db doesn't have BLAT findClosestServer(&db, &organism); allResults = cartUsualBoolean(cart, "allResults", allResults);