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); }