diff --git a/backend/src/api/protein.py b/backend/src/api/protein.py index 87dd20dc..4e55a169 100644 --- a/backend/src/api/protein.py +++ b/backend/src/api/protein.py @@ -105,11 +105,30 @@ def str_as_file_stream(input_str: str, filename_as: str) -> StreamingResponse: ) +def get_residue_bfactors(pdb: PDB): + chains = {} + for chain in pdb.structure.get_chains(): + chains[chain.get_id()] = [] + for r in chain.get_residues(): + for a in r.get_atoms(): + chains[chain.get_id()].append(a.bfactor) + break + return chains + + """ ENDPOINTS TODO: add the other protein types here instead of in api_types.py """ +@router.get("/protein/pLDDT/{protein_name:str}", response_model=dict[str, list[float]]) +def get_pLDDT_given_protein(protein_name: str): + if protein_name_found(protein_name): + pdb = parse_protein_pdb(protein_name, encoding="file") + return get_residue_bfactors(pdb) + return {} + + class UploadPNGBody(CamelModel): protein_name: str base64_encoding: str diff --git a/backend/src/api/search.py b/backend/src/api/search.py index b634246a..7846fb30 100644 --- a/backend/src/api/search.py +++ b/backend/src/api/search.py @@ -229,3 +229,42 @@ def search_venome_similar(protein_name: str): raise HTTPException(500, "Error getting protein descriptions") return formatted + + +@router.get( + "/search/venome/similar/{protein_name:str}/{protein_compare:str}", + response_model=SimilarProtein, +) +def search_venome_similar_compare(protein_name: str, protein_compare: str): + target = stored_pdb_file_name(protein_compare) + # ignore the first since it's itself as the most similar + try: + similar = easy_search( + stored_pdb_file_name(protein_name), + target, + out_format="target,prob,evalue,qstart,qend", + ) # qend,qstart refer to alignment + formatted = [ + SimilarProtein( + name=name.rstrip(".pdb"), + prob=prob, + evalue=evalue, + qstart=qstart, + qend=qend, + alntmscore=0, + ) + for [name, prob, evalue, qstart, qend] in similar + ] + except Exception: + raise HTTPException(404, "Error in 'foldseek easy-search' command") + + try: + # populate protein descriptions for the similar proteins + descriptions = get_descriptions([s.name for s in formatted]) + if descriptions is not None: + for f, d in zip(formatted, descriptions): + f.description = d + except Exception: + raise HTTPException(500, "Error getting protein descriptions") + + return formatted[0] diff --git a/backend/src/api/users.py b/backend/src/api/users.py index d896fb7a..72847a8d 100644 --- a/backend/src/api/users.py +++ b/backend/src/api/users.py @@ -24,7 +24,7 @@ def login(body: LoginBody): if entry_sql is None or len(entry_sql) == 0: return LoginResponse(token="", error="Invalid Email or Password") - # Grabs the stored hash and admin. + # Grabs the stored hash and admin status. password_hash, admin = entry_sql[0] # Returns "incorrect email/password" message if password is incorrect. diff --git a/backend/src/data/pdbAlphaFold/.gitignore b/backend/src/data/pdbAlphaFold/.gitignore index eadd157d..2da6d9aa 100644 --- a/backend/src/data/pdbAlphaFold/.gitignore +++ b/backend/src/data/pdbAlphaFold/.gitignore @@ -1 +1 @@ -*.pdb +*.pdb diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 00000000..eafdaa14 --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,52 @@ +# Authentication +Venome uses a first-party authentication scheme. + +## Login Flow +1. User goes to login page and provides username and password and presses "Log In". Client sends POST request to backend's */users/login* API endpoint. The request contains JSON with the username and password. See *submitForm()* in [`Login.svelte`](../frontend/src/routes/Login.svelte) +2. Backend verifies provided information against database's username and hashed/salted password If verified, returns a JSON Web Token (JWT) to the frontend, and if not verified, sends an error. See *login()* in [`users.py`](../backend/src/api/users.py) +3. Frontend, if the user is verified: + * Stores the JWT into the browser as a cookie. See *Cookies.set()* in [`Login.svelte`](../frontend/src/routes/Login.svelte) + * Sets *user* svelte store **loggedIn** attributes to true. See *$user.loggedIn = true* in [`Login.svelte`](../frontend/src/routes/Login.svelte) + * The JWT cookie and user store are used to access restricted functionality in the website and API. See [`Impelentation Tips`](#implementation-tips). +4. When the user reloads the website, the Frontend checks to see if they're logged in by looking at the browser cookie and sets the *user* store accordingly. See *onMount()* in [`Header.svelte`](../frontend/src/routes/Login.svelte) +5. When the user presses "log out", it sends them back to the login page. This clears the auth cookie from the browser and unsets the *user* store attributes. See *onMount()* in [`Login.svelte`](../frontend/src/routes/Login.svelte) + +## Implementation Tips +There are a few functions we created to make it easy to lock elements and endpoints behind authentication. + +### Backend: Locking an API call behind authentication +1. Import *requires_authentication()* from auth.py +2. Call *requires_authentication()* at the top of the API call you want to restrict. + +In /backend/src/auth.py, *requires_authentication()* takes in a Request object as a parameter, checks if it has an authorization header, and validates the contained JWT against the database to determine if the user is an admin. If they aren't an admin, it raises an HTTP Exception; Otherwise, the API call proceeds as normal. + +For an example, see the *upload_tutorial()* in [`tutorials.py`](../backend/src/api/tutorials.py). + +### Frontend: Accessing a locked API call +1. import *setToken()* /lib/backend.ts +2. Call *setToken()* before making the restricted API call. + +In /frontend/src/lib/backend.ts, *setToken()* reads the authentication JWT stored in the user's browser cookie, and sets the TOKEN header for outgoing HTTP requests to that token. + +For an example, look at [`Edit.svelte`](../frontend/src/routes/Edit.svelte) and search for setToken. + +### Frontend: Hiding pages or elements if the user isn't logged in +1. Check if *$user.loggedIn* is true or false. +2. Hide the element or redirect as needed. + +To track whether a user is logged in for the purposes of hiding elements, we use a Svelte store called "user" (defined in [`user.ts`](../frontend/stores/user.ts)). This store has an attribute called **loggedIn.** The **loggedIn** attribute is set to *true* either when the user has just logged in, or if they open the website while they have an authentication cookie stored. The attribute is set to *false* when the user logs out and defaults to *false* if the site is reloaded. + +Svelte provides an easy shorthand to interface with a svelte store; You can simply type in "$user.*attribute*" to look at the contents of any store attribute. The other user store attributes (*username*, *admin*, etc.) could be used in a similar way in the future, but we are not using them at this time. + +You can see some examples of use-cases in [`Header.svelte`](../frontend/src/lib/Header.svelte) and [`Upload.svelte`](../frontend/src/routes/Upload.svelte); Just search for *$user*. + +## Concerns and To-Dos +* Originally, we had a choice between using first-party or third-party authentication. We settled on using first-party authentication because it was quicker and easier to implement, but third-party authentication through something like Google would be a more secure choice. The Oauth 2 protocol (which services like Google use for third-party authentication) uses JWT-based authentication similar to what we did here, so some of the code we've already written could be adapted for use in third-party authentication. +* Our frontend and backend currently communicate over HTTP instead of HTTPS. This is mostly a concern where we send usernames and passwords (e.g. the /users/login API endpoint), but this should probably be changed even if switching to third-party authentication. +* The authentication token has an "exp" which lists a 24 hour expiration date (see generate_auth_token() in auth.py), but we don't use this anywhere. This can either be used in the future, or *probably* safely removed. +* Right now, we're only using the **loggedIn** attribute of the user store. While we do have attributes like **admin** and **username**, they are untested and probably have bugs. +* We implemented this authentication scheme with limited security experience. As such, there may be more flaws than listed here which we did not consider. + +## Library References +* [`PyJWT`](https://github.com/jpadilla/pyjwt) for JWT generation and verification in the backend. +* [`js-cookie`](https://github.com/js-cookie/js-cookie) for securely storing JWTs in browser cookies in the frontend. \ No newline at end of file diff --git a/frontend/src/lib/Header.svelte b/frontend/src/lib/Header.svelte index 26899adf..7d429113 100644 --- a/frontend/src/lib/Header.svelte +++ b/frontend/src/lib/Header.svelte @@ -13,9 +13,12 @@ import Cookies from "js-cookie"; import ProteinIcon from "../lib/ProteinIcon.svelte"; - // Checking if the user has a cookie. - // If they do, set user status for the whole site. onMount(async () => { + /** + * Checking if the user has a cookie. If they do, set user svelte store loggin attribute. + * Done here because the header is loaded first, which means user can still directly navigate + * to restricted pages if they refresh while logged in. + */ if (Cookies.get("auth")) { $user.loggedIn = true; } diff --git a/frontend/src/lib/Molstar.svelte b/frontend/src/lib/Molstar.svelte index b2ffcfe6..e4d67fd1 100644 --- a/frontend/src/lib/Molstar.svelte +++ b/frontend/src/lib/Molstar.svelte @@ -1,7 +1,12 @@ diff --git a/frontend/src/lib/openapi/services/DefaultService.ts b/frontend/src/lib/openapi/services/DefaultService.ts index 678d50f9..4437b730 100644 --- a/frontend/src/lib/openapi/services/DefaultService.ts +++ b/frontend/src/lib/openapi/services/DefaultService.ts @@ -121,6 +121,49 @@ export class DefaultService { }, }); } + /** + * Search Venome Similar Compare + * @param proteinName + * @param proteinCompare + * @returns SimilarProtein Successful Response + * @throws ApiError + */ + public static searchVenomeSimilarCompare( + proteinName: string, + proteinCompare: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/search/venome/similar/{protein_name}/{protein_compare}', + path: { + 'protein_name': proteinName, + 'protein_compare': proteinCompare, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Plddt Given Protein + * @param proteinName + * @returns number Successful Response + * @throws ApiError + */ + public static getPLddtGivenProtein( + proteinName: string, + ): CancelablePromise>> { + return __request(OpenAPI, { + method: 'GET', + url: '/protein/pLDDT/{protein_name}', + path: { + 'protein_name': proteinName, + }, + errors: { + 422: `Validation Error`, + }, + }); + } /** * Get Pdb File * @param proteinName diff --git a/frontend/src/lib/venomeMolstarUtils.ts b/frontend/src/lib/venomeMolstarUtils.ts index d3342928..e9c2ad58 100644 --- a/frontend/src/lib/venomeMolstarUtils.ts +++ b/frontend/src/lib/venomeMolstarUtils.ts @@ -1,6 +1,13 @@ import { BACKEND_URL } from "./backend"; import { PDBeMolstarPlugin } from "../../venome-molstar/lib"; import type { InitParams } from "../../venome-molstar/lib/spec"; +import type { QueryParam } from "../../venome-molstar/lib/helpers"; +import * as d3 from "d3"; + +export type ResidueColor = { r: number; g: number; b: number }; +export type ChainId = string; +export type ChainColors = { [chainId: ChainId]: ResidueColor[] }; +export type ChainpLDDT = { [chainId: ChainId]: number[] }; export async function screenshotMolstar(initParams: Partial) { const { div, molstar } = await renderHeadless(initParams); @@ -74,3 +81,62 @@ export const defaultInitParams = (name: string): Partial => ({ "controlInfo", ], }); + +export function colorResidues({ + colors = [], + entity_id = undefined, + struct_asym_id = "A", +}: { + colors?: { r: number; g: number; b: number }[]; + entity_id?: string; + struct_asym_id?: string; +} = {}): QueryParam[] { + let selections: QueryParam[] = []; + for (let i = 0; i < colors.length; i++) { + const color = colors[i]; + const residueIndex = i + 1; + const residueColoring = { + entity_id, + struct_asym_id, + color, + start_residue_number: residueIndex, + end_residue_number: residueIndex, + }; + selections.push(residueColoring); + } + return selections; +} + +export const alphafoldThresholds = ["> 90", "> 70", "> 50", "< 50"]; +export const alphafoldColorscheme = [ + "rgb(1,83,214)", // > 90 + "rgb(100,203,243)", // > 70 + "rgb(255,219,18)", // > 50 + "rgb(255,125,69)", // < 50 +]; +export function pLDDTToAlphaFoldResidueColors(pLDDT: number[]): ResidueColor[] { + const colors = pLDDT.map((d) => { + if (d > 90) { + return alphafoldColorscheme[0]; + } else if (d > 70) { + return alphafoldColorscheme[1]; + } else if (d > 50) { + return alphafoldColorscheme[2]; + } else { + return alphafoldColorscheme[3]; + } + }); + return colors.map((c) => { + const rgb = d3.color(c)!.rgb()!; + return { r: rgb.r, g: rgb.g, b: rgb.b }; + }); +} + +export function pLDDTToResidueColors(pLDDT: number[]): ResidueColor[] { + const interpolate = d3.interpolateSpectral; + const colors = pLDDT.map((d) => interpolate(d / 100)); + return colors.map((c) => { + const rgb = d3.color(c)!.rgb()!; + return { r: rgb.r, g: rgb.g, b: rgb.b }; + }); +} diff --git a/frontend/src/routes/Align.svelte b/frontend/src/routes/Align.svelte index f0d0378d..20e28915 100644 --- a/frontend/src/routes/Align.svelte +++ b/frontend/src/routes/Align.svelte @@ -2,19 +2,22 @@ import TMAlignEntry from "../lib/ProteinLinkCard.svelte"; import { onMount } from "svelte"; - import { Backend, BACKEND_URL, type ProteinEntry } from "../lib/backend"; + import { Backend, BACKEND_URL, type SimilarProtein, type ProteinEntry } from "../lib/backend"; import Molstar from "../lib/Molstar.svelte"; import DelayedSpinner from "../lib/DelayedSpinner.svelte"; import { DownloadOutline } from "flowbite-svelte-icons"; import { Button } from "flowbite-svelte"; import * as d3 from "d3"; import { undoFormatProteinName } from "../lib/format"; + import AlignBlock from "../lib/AlignBlock.svelte"; export let proteinA: string; export let proteinB: string; let combined = proteinA + "/" + proteinB; let entryA: ProteinEntry | null = null; let entryB: ProteinEntry | null = null; + let foldseekData: SimilarProtein; + let foldseekError = false; let error = false; const dark2green = d3.schemeDark2[0]; @@ -34,6 +37,17 @@ entryA = await Backend.getProteinEntry(proteinA); entryB = await Backend.getProteinEntry(proteinB); + try { + foldseekData = await Backend.searchVenomeSimilarCompare(proteinA, proteinB) + } catch (e) { + console.error(e); + console.error( + "NEED TO DOWNLOAD FOLDSEEK IN THE SERVER. SEE THE SERVER ERROR MESSAGE." + ); + foldseekError = true; + } + + // if we could not find the entry, the id is garbo if (entryA == null || entryB == null) error = true; console.log(entryA, entryB); @@ -58,6 +72,33 @@ +

+ Foldseek Data +

+ {#if foldseekData === undefined && !foldseekError} + + {:else if foldseekData !== undefined} +
+ Prob. Match: {foldseekData.prob} +
+
+ E-Value: {foldseekData.evalue} +
+
+ Region of Similarity + +
+ {/if}
+ {/if} + + {#if Object.keys(chainColors).length > 0} + {#each alphafoldThresholds as at, i} +
+ {at} +
+ {/each} + {/if} +
@@ -178,4 +233,16 @@ font-weight: 500; color: var(--primary-700); } + .legend-chip { + --color: black; + color: rgb(0, 0, 0); + background-color: var(--color); + border-radius: 3px; + font-size: 12px; + + padding-left: 5px; + padding-right: 5px; + padding-top: 2px; + padding-bottom: 2px; + } diff --git a/galaxy/delete_all.py b/galaxy/delete_all.py index 58a897e4..33e25949 100644 --- a/galaxy/delete_all.py +++ b/galaxy/delete_all.py @@ -1,7 +1,7 @@ import requests import os -DIR = "../backend/src/data/pdbAlphaFold" +DIR = os.path.join('..', 'backend', 'src', 'data', 'pdbAlphaFold') def delete_protein_files():