7d56cd6635651dfd4c9926d062d92ad7b65a3e80
chmalee
  Wed Jun 3 14:52:52 2026 -0700
More myVariants changes: make the share to accept a comma-sep list of usernames, allow both the read/edit flag and the usernames setting to be editable after creation. When editing usernames, add a confirmation if the field is left blank that this will make the share viewable/editable by anyone with the link, refs #33808

diff --git src/hg/hgTracks/myVariantsTrack.c src/hg/hgTracks/myVariantsTrack.c
index 791e2b3d0f6..ca51d61da38 100644
--- src/hg/hgTracks/myVariantsTrack.c
+++ src/hg/hgTracks/myVariantsTrack.c
@@ -1011,36 +1011,89 @@
 /* Write a single share record as a JSON object into jw. */
 {
 jsonWriteObjectStart(jw, NULL);
 jsonWriteNumber(jw, "id", share->id);
 jsonWriteString(jw, "ownerUser", share->ownerUser);
 jsonWriteString(jw, "shareToken", share->shareToken);
 jsonWriteString(jw, "project", share->project);
 jsonWriteString(jw, "db", share->db);
 jsonWriteNumber(jw, "permission", share->permission);
 jsonWriteString(jw, "targetUser", share->targetUser);
 jsonWriteString(jw, "label", share->label);
 jsonWriteString(jw, "createdAt", share->createdAt);
 jsonWriteObjectEnd(jw);
 }
 
+static char *cleanShareTargets(struct sqlConnection *conn, char *raw)
+/* Parse a comma-separated recipient string into a normalized comma-no-space
+ * list: trim each name, drop empties, de-dup, and verify each exists in
+ * gbMembers. Calls apiError(400,...) (does not return) on an unknown name or
+ * an overlong list. Returns a string the caller must freeMem, or NULL for an
+ * empty list (anyone with link). */
+{
+if (isEmpty(raw))
+    return NULL;
+struct slName *names = slNameListFromComma(raw);
+struct slName *name;
+struct slName *cleanList = NULL;
+for (name = names; name != NULL; name = name->next)
+    {
+    trimSpaces(name->name);
+    if (isNotEmpty(name->name))
+        slNameStore(&cleanList, name->name);
+    }
+slNameFreeList(&names);
+if (cleanList == NULL)
+    return NULL;
+slReverse(&cleanList);  /* slNameStore prepends; restore input order */
+
+struct dyString *dy = dyStringNew(256);
+struct slName *c;
+for (c = cleanList; c != NULL; c = c->next)
+    {
+    char gbq[512];
+    sqlSafef(gbq, sizeof(gbq),
+        "select count(*) from gbMembers where userName = BINARY '%s'", c->name);
+    if (sqlQuickNum(conn, gbq) == 0)
+        {
+        char msg[512];
+        safef(msg, sizeof(msg),
+            "user '%s' not found (the username is case-sensitive) - check the"
+            " spelling, or leave the field blank to make an 'anyone with link' share",
+            c->name);
+        apiError(400, msg);
+        }
+    if (dy->stringSize > 0)
+        dyStringAppendC(dy, ',');
+    dyStringAppend(dy, c->name);
+    }
+slNameFreeList(&cleanList);
+if (dy->stringSize > 500)
+    {
+    dyStringFree(&dy);
+    apiError(400, "too many recipients - the username list is too long");
+    }
+return dyStringCannibalize(&dy);
+}
+
 static boolean isStateChangingShareAction(char *action)
 /* Returns TRUE for actions that modify the share table.  Those require an
  * hgsid match to defend against CSRF; read-only actions don't, because the
  * response only goes to the originating cookie-bearing browser. */
 {
-return sameString(action, "createShare") || sameString(action, "revokeShare");
+return sameString(action, "createShare") || sameString(action, "revokeShare")
+    || sameString(action, "setSharePermission") || sameString(action, "setShareTargets");
 }
 
 void myVariantsShareApiHandler(char *action)
 /* Handle share API requests. Outputs JSON to stdout and calls exit(0).
  * Called from main() before cartHtmlShell. */
 {
 char *userName = getUserName();
 if (userName == NULL)
     apiError(401, "not logged in");
 
 /* CSRF protection for state-changing actions: require the request to carry
  * an hgsid matching the user's session.  An attacker page can fire requests
  * with the user's cookies but cannot read or guess the session id, so this
  * blocks cross-site forgery of share creation/revocation. */
 if (isStateChangingShareAction(action))
@@ -1062,58 +1115,50 @@
     {
     hDisconnectCentral(&conn);
     apiError(500, "myVariantsShares table not found - contact genome-www@ucsc.edu");
     }
 
 if (sameString(action, "createShare"))
     {
     char *project = cgiOptionalString("project");
     if (project == NULL)
         apiError(400, "missing required parameter: project");
     int permission = cgiOptionalInt("permission", MYVAR_PERM_READONLY);
     if (permission != MYVAR_PERM_READONLY && permission != MYVAR_PERM_READWRITE)
         apiError(400, "permission must be 0 or 1");
     char *targetUser = cgiOptionalString("targetUser");
     char *label = cgiOptionalString("label");
-    /* Reject overlong fields before MySQL would (varchar(255)).  Use 200 to
-     * leave room for any prefixing/quoting we add downstream. */
-    if (strlen(project) > 200
-        || (targetUser && strlen(targetUser) > 200)
-        || (label && strlen(label) > 200))
-        apiError(400, "project, targetUser, and label must each be <= 200 chars");
+    /* Reject overlong fields before MySQL would.  Use 200 to leave room for any
+     * prefixing/quoting we add downstream; the recipient list is checked in
+     * cleanShareTargets against the wider targetUser column. */
+    if (strlen(project) > 200 || (label && strlen(label) > 200))
+        apiError(400, "project and label must each be <= 200 chars");
     /* Project must be "*" (all) or one of the owner's existing project values.
      * Prevents creating misleading share URLs that filter on a non-existent
      * project name. */
     if (!sameString(project, "*"))
         {
         struct slName *projects = myVariantsGetProjects(userName);
         boolean found = slNameInList(projects, project);
         slFreeList(&projects);
         if (!found)
             apiError(400, "project does not exist for current user");
         }
-    if (isNotEmpty(targetUser))
-        {
-        char gbq[512];
-        sqlSafef(gbq, sizeof(gbq),
-            "select count(*) from gbMembers where userName='%s'", targetUser);
-        if (sqlQuickNum(conn, gbq) == 0)
-            apiError(400, "targetUser not found - check the spelling, or omit"
-                " targetUser to make an 'anyone with link' share");
-        }
+    char *cleanTargets = cleanShareTargets(conn, targetUser);
     struct myVariantsShare *share = myVariantsCreateShare(conn, userName,
-        project, db, permission, targetUser, label);
+        project, db, permission, cleanTargets, label);
+    freeMem(cleanTargets);
     struct jsonWrite *jw = jsonWriteNew();
     jsonWriteObjectStart(jw, NULL);
     jsonWriteString(jw, "status", "ok");
     jsonWriteString(jw, "token", share->shareToken);
     jsonWriteNumber(jw, "id", share->id);
     jsonWriteObjectEnd(jw);
     myVariantsShareFree(&share);
     hDisconnectCentral(&conn);
     apiSuccess(jw);
     }
 else if (sameString(action, "getShares"))
     {
     struct myVariantsShare *shares = myVariantsGetSharesForOwner(conn, userName, db);
     struct jsonWrite *jw = jsonWriteNew();
     jsonWriteObjectStart(jw, NULL);
@@ -1147,30 +1192,65 @@
 else if (sameString(action, "revokeShare"))
     {
     char *token = cgiOptionalString("shareToken");
     if (token == NULL)
         apiError(400, "missing required parameter: shareToken");
     boolean revoked = myVariantsRevokeShare(conn, token, userName);
     hDisconnectCentral(&conn);
     if (!revoked)
         apiError(404, "share not found");
     struct jsonWrite *jw = jsonWriteNew();
     jsonWriteObjectStart(jw, NULL);
     jsonWriteString(jw, "status", "ok");
     jsonWriteObjectEnd(jw);
     apiSuccess(jw);
     }
+else if (sameString(action, "setSharePermission"))
+    {
+    char *token = cgiOptionalString("shareToken");
+    if (token == NULL)
+        apiError(400, "missing required parameter: shareToken");
+    int permission = cgiOptionalInt("permission", MYVAR_PERM_READONLY);
+    if (permission != MYVAR_PERM_READONLY && permission != MYVAR_PERM_READWRITE)
+        apiError(400, "permission must be 0 or 1");
+    boolean updated = myVariantsSetSharePermission(conn, token, userName, permission);
+    hDisconnectCentral(&conn);
+    if (!updated)
+        apiError(404, "share not found");
+    struct jsonWrite *jw = jsonWriteNew();
+    jsonWriteObjectStart(jw, NULL);
+    jsonWriteString(jw, "status", "ok");
+    jsonWriteObjectEnd(jw);
+    apiSuccess(jw);
+    }
+else if (sameString(action, "setShareTargets"))
+    {
+    char *token = cgiOptionalString("shareToken");
+    if (token == NULL)
+        apiError(400, "missing required parameter: shareToken");
+    char *cleanTargets = cleanShareTargets(conn, cgiOptionalString("targetUser"));
+    boolean updated = myVariantsSetShareTargets(conn, token, userName, cleanTargets);
+    freeMem(cleanTargets);
+    hDisconnectCentral(&conn);
+    if (!updated)
+        apiError(404, "share not found");
+    struct jsonWrite *jw = jsonWriteNew();
+    jsonWriteObjectStart(jw, NULL);
+    jsonWriteString(jw, "status", "ok");
+    jsonWriteObjectEnd(jw);
+    apiSuccess(jw);
+    }
 else
     {
     hDisconnectCentral(&conn);
     apiError(400, "unknown action");
     }
 }
 
 void myVariantsProcessShareParam()
 /* Check for myVarShare CGI param, validate the share token, and store
  * the share reference in the cart. Must be called before track loading. */
 {
 char *token = cgiOptionalString(MYVAR_SHARE_CGI_VAR);
 if (isEmpty(token))
     return;
 
@@ -1185,41 +1265,41 @@
 struct sqlConnection *conn = hConnectCentral();
 if (!sqlTableExists(conn, "myVariantsShares"))
     {
     hDisconnectCentral(&conn);
     return;
     }
 
 struct myVariantsShare *share = myVariantsGetShareByToken(conn, token);
 hDisconnectCentral(&conn);
 if (share == NULL)
     {
     notify("A share link is no longer valid (it may have been revoked). The shared track isn't available, but the rest of the browser works normally.", "myVarShareRevoked");
     return;
     }
 
-/* Targeted shares require the matching logged-in user. Shares with no targetUser
- * ("anyone with link") are accessible to anon users too. */
+/* Targeted shares require a logged-in user in the share's recipient list.
+ * Shares with no targetUser ("anyone with link") are accessible to anon users too. */
 if (isNotEmpty(share->targetUser))
     {
     if (userName == NULL)
         {
         notify("A shared track in this view is limited to a specific user. You can keep browsing.", "myVarShareNeedLogin");
         myVariantsShareFree(&share);
         return;
         }
-    if (!sameString(share->targetUser, userName))
+    if (!myVariantsShareAllowsUser(share, userName))
         {
         notify("A shared track in this view wasn't shared with your account. You can keep browsing; the track just won't appear.", "myVarShareWrongUser");
         myVariantsShareFree(&share);
         return;
         }
     }
 
 /* Store in cart so the shared track loader can find it during track loading */
 char cartVar[256];
 safef(cartVar, sizeof(cartVar), MYVAR_SHARED_CART_PREFIX "%s", token);
 char *cartVal = myVariantsShareCartValue(share);
 cartSetString(cart, cartVar, cartVal);
 freeMem(cartVal);
 myVariantsShareFree(&share);
 }