cb99f0b11bdeee5dfa76064d38b6410da0f4a709
max
  Thu Sep 10 00:55:21 2026 -0700
Centralize CGI Content-Type printing in one cgiPrintContentType() helper

Around 90 places across the tree hand-rolled the CGI response header, each
with its own spelling: "Content-Type:" or "Content-type:", \n or \r\n, and
the terminating blank line written as part of the same string, as a separate
puts("\n") (which emits two newlines, so a stray blank line led the body) or
as printf("\r\n\r\n") (two blank lines).  A handful forgot the terminator
entirely and relied on a following header to supply it.

cgiPrintContentType() in lib/cheapcgi.c now writes the Content-Type line and
the blank line that ends the header.  Header lines are not ordered, so the
callers that also send Status, Set-Cookie, Content-Disposition, Content-Length
or X-Sendfile write those first and call this last to close the header; that
keeps it to a single helper rather than a print-the-line / end-the-header pair
that a caller can half-use.  cart.c's existing httpHeaders list already worked
this way.

Only the CGI response path is touched.  The dyStringPrintf("Content-type: ...")
calls that build outgoing HTTP *requests* (genomeSpace, oauthLogin, eapMetaSync,
edwWebAuthLogin, ga4ghToBed) are unrelated and left alone.

Also fills out the apiKey error message in botDelay.c to say where to create a
key and that keys are server-specific.

No behavior change on the wire beyond dropping those stray blank lines and
adding the missing newline after Retry-After.

diff --git src/hg/hgSession/hgSession.c src/hg/hgSession/hgSession.c
index 1ac413e611e..8c062c68dc5 100644
--- src/hg/hgSession/hgSession.c
+++ src/hg/hgSession/hgSession.c
@@ -746,31 +746,31 @@
        "TARGET=_BLANK>Session Gallery</A>.</LI>\n",
        dyUrl->string);
 printf("</UL>\n");
 dyStringFree(&dyUrl);
 }
 
 void doMainPage(char *userName, char *message)
 /* Login status/links and session controls. */
 {
 if (sessionNewPageActive())
     {
     doMainPageNew(userName, message);
     return;
     }
 cspWriteResponseHeader();
-puts("Content-Type:text/html\n");
+cgiPrintContentType("text/html");
 if (loginSystemEnabled() || wikiLinkEnabled())
     {
     if (userName)
 	welcomeUser(userName);
     else
 	offerLogin();
     if (isNotEmpty(message))
 	{
 	if (cartVarExists(cart, hgsDoSessionDetail))
 	    webNewSection("Session Details");
 	else
 	    webNewSection("Updated Session");
 	puts(message);
 	}
     showSessionControls(userName, TRUE, TRUE);
@@ -1039,57 +1039,57 @@
     cartCheckForCustomTracks(cart, dyMessage);
     }
 else
     dyStringPrintf(dyMessage,
 	  "Sorry, required table %s does not exist yet in the central "
 	  "database (%s).  Please ask a developer to create it using "
 	  "kent/src/hg/lib/namedSessionDb.sql .",
 	  namedSessionTable, sqlGetDatabase(conn));
 hDisconnectCentral(&conn);
 return dyStringCannibalize(&dyMessage);
 }
 
 static void saveSessionJsonError(struct sqlConnection *conn, char *message)
 /* Emit a JSON error response for the "Share a link" AJAX endpoints and disconnect. */
 {
-puts("Content-Type:application/json\n");
+cgiPrintContentType("application/json");
 printf("{\"error\": \"%s\"}\n", jsonStringEscape(message));
 hDisconnectCentral(&conn);
 }
 
 static boolean namedSessionExists(struct sqlConnection *conn, char *encUserName,
                                   char *encSessionName)
 /* Is there already a session by this name for this user?  Both names must be encoded the way they
  * are stored, i.e. through cgiEncodeFull(). */
 {
 char query[1024];
 sqlSafef(query, sizeof query,
          "select count(*) from %s where userName = '%s' and sessionName = '%s'",
          namedSessionTable, encUserName, encSessionName);
 return sqlQuickNum(conn, query) > 0;
 }
 
 static void saveSessionJsonResult(struct sqlConnection *conn, char *encUserName,
                                   char *encSessionName, char *sessionName, char *warning)
 /* 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".
  * warning (may be NULL) is added as "warning" for something that went wrong alongside a save that
  * did succeed, such as a thumbnail the server could not build. */
 {
 struct dyString *dyUrl = dyStringNew(0);
 addSessionLink(dyUrl, encUserName, encSessionName, FALSE, TRUE);
-puts("Content-Type:application/json\n");
+cgiPrintContentType("application/json");
 printf("{\"name\": \"%s\", \"url\": \"%s\"", jsonStringEscape(sessionName),
        jsonStringEscape(dyUrl->string));
 if (isNotEmpty(warning))
     printf(", \"warning\": \"%s\"", jsonStringEscape(warning));
 puts("}");
 dyStringFree(&dyUrl);
 hDisconnectCentral(&conn);
 }
 
 static int sessionSharedLevel(struct sqlConnection *conn, char *encUserName, char *encSessionName)
 /* Return the sharing level of this user's session: 0 private, 1 shared by link, 2 in the public
  * listing.  Returns -1 when the user has no session by that name, which the shared column cannot
  * express (it is NOT NULL), so the AJAX endpoints can say so instead of reporting a no-op as a
  * success. */
 {
@@ -1113,31 +1113,31 @@
 
 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");
+cgiPrintContentType("application/json");
 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": <session name>, "url": <shareable link>}.  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, 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))
     {
@@ -1237,31 +1237,31 @@
     {
     /* 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))
         {
         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 && namedSessionExists(conn, encUserName, encSessionName))
         {
-        puts("Content-Type:application/json\n");
+        cgiPrintContentType("application/json");
         printf("{\"exists\": true}\n");
         hDisconnectCentral(&conn);
         return;
         }
     }
 
 saveCartAsSession(conn, encUserName, encSessionName, 1);  /* shared by link */
 saveSessionJsonResult(conn, encUserName, encSessionName, sessionName, NULL);
 }
 
 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"}. */
 {
@@ -2519,31 +2519,31 @@
         hashFree(&dbConnCache);
         }
     hDisconnectCentral(&conn);
     }
 jsonWriteListEnd(jw);   // sessions
 }
 
 void doMainPageNew(char *userName, char *message)
 /* Render the experimental client-rendered Sessions page: framework header (gold "My Sessions"
  * band), the experimental banner, an empty #sessionApp container, and the hgSessionData JSON that
  * hgSession.js reads to build the UI. */
 {
 if (isNotEmpty(cartOptionalString(cart, "measureTiming")))
     hgSessionTiming = perfTimerNew();   /* times the page; emitted as hgSessionData.timing */
 cspWriteResponseHeader();
-puts("Content-Type:text/html\n");
+cgiPrintContentType("text/html");
 cartWebStart(cart, NULL, "My Sessions");
 jsInit();
 jsIncludeDataTablesLibs();
 webIncludeResourceFile("gbModern.css");
 webIncludeResourceFile("hgSession.css");
 jsIncludeFile("hgSession.js", NULL);
 
 printSessionNewPageBanner(TRUE);
 if (isNotEmpty(message))
     printf("<div class='gbBanner'>%s</div>\n", message);
 
 printf("<div id='sessionApp' class='gbApp'></div>\n");
 
 struct jsonWrite *jw = jsonWriteNew();
 jsonWriteObjectStart(jw, NULL);
@@ -2553,31 +2553,31 @@
 perfTimerJson(hgSessionTiming, jw, "timing");
 jsonWriteObjectEnd(jw);
 jsInlineF("var hgSessionData = %s;\n", jw->dy->string);
 jsonWriteFree(&jw);
 perfTimerFree(&hgSessionTiming);
 
 cartWebEnd();
 }
 
 /* ---- JSON action endpoints for the experimental page's inline table actions ---- */
 
 static void saveSessionJsonOk(struct sqlConnection *conn, char *extraFields)
 /* Emit {"success": true[, <extraFields>]} and disconnect.  extraFields (may be NULL) is inserted
  * verbatim after "success": true, e.g. ", \"shared\": 2". */
 {
-puts("Content-Type:application/json\n");
+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();
 char *sessionName = trimSpaces(cloneString(cgiUsualString(hgsOldSessionName, "")));
 if (isEmpty(userName))
     { saveSessionJsonError(conn, "Please log in and try again."); return; }
 if (isEmpty(sessionName))
     { saveSessionJsonError(conn, "No session was specified."); return; }
 char *encUserName = cgiEncodeFull(userName);
 char *encSessionName = cgiEncodeFull(sessionName);