Repository: streamlit/streamlit
Pull request: Showing an error when it's not localhost and has no mapbox token
**Issue:** Fix #1136 #812 

**Description:** As it is not possible to render a Mapbox without any token we are delimiting the use locally.

- Prevent fetching token when it's not localhost or hello.py 
- Using axios instead of fetch
- Added new step for cypress job in which we set the mapbox token
- Unittest

---

**Contribution License Agreement**

By submiting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.


Problem statement:
Remove Streamlit Mapbox API key
_Edit: apparently this is _not_ how Mapbox works. See Update section below._

If I understand correctly how Mapbox works, I think we don't need an API key by default since Mapbox throttles usage of their maps based on IP address (which is user-specific).

Can we just remove the default API key? **And we'd need to allow users to enter their own API key, of course.** 

---

Update:

DeckGL depends on Mapbox to show maps. Mapbox requires developers to have a token. Up to now, Streamlit hard-coded a token to make people's lives easier. The problem is that for the past few months we've started going over the free QPS limit of that token, and so we've been paying for everyone's usage of maps.

So let's fix this!

Proposal:
- Whenever a users tries to use DeckGL and they didn't provide a token (this is done via the config file), we'll show an error explaining what they need to do.
- We'll update our docs to make this abundantly clear.
- To make the `streamlit hello` experience nice, the map in that app will continue to use our own paid token. But we'll take steps to make sure only that app can use that token, as much as possible.

Hints from issue discussion:
## Changes: 

- When you use a DeckGL chart:
    - If running `streamlit hello` AND running from localhost:
        - The map is shown using our own token (which we pay for)
    - Else:
        - If the user has a token:
            - We show the map
        - Else:
            - We show an error telling the user to pass in a token
            - AND we don't show the map

## Discussion
https://discuss.streamlit.io/t/showing-an-error-when-its-not-localhost-and-has-no-mapbox-token/2418

Commit message: Update lib/streamlit/config.py

Co-authored-by: Amey Deshpande <10554902+Amey-D@users.noreply.github.com>

Patch to review:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index e2807b3a3b19..02d6fc93e26e 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -481,12 +481,19 @@ jobs:
       - run:
           name: Install Cypress dependencies
           command: |
-            ${SUDO} apt-get install -y xvfb libgtk2.0-0 libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2
+            ${SUDO} apt-get install -y xvfb libgtk2.0-0 libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 jq curl
 
       - run:
-          name: Init credentials
+          name: Init config
           command: |
             mkdir ~/.streamlit
+            MAPBOX_TOKEN=$(curl -sS https://data.streamlit.io/tokens.json | jq -r '.mapbox')
+            echo '[mapbox]' >  ~/.streamlit/config.toml
+            echo 'token = "'$MAPBOX_TOKEN'"' >> ~/.streamlit/config.toml
+
+      - run:
+          name: Init credentials
+          command: |
             echo '[general]' >  ~/.streamlit/credentials.toml
             echo 'email = "test@streamlit.io"' >> ~/.streamlit/credentials.toml
 
diff --git a/frontend/src/hocs/withMapboxToken/MapboxToken.test.ts b/frontend/src/hocs/withMapboxToken/MapboxToken.test.ts
index 91d3c80cffe9..9d8dc00279b9 100644
--- a/frontend/src/hocs/withMapboxToken/MapboxToken.test.ts
+++ b/frontend/src/hocs/withMapboxToken/MapboxToken.test.ts
@@ -15,11 +15,15 @@
  * limitations under the License.
  */
 
-import { MapboxToken, TOKENS_URL } from "hocs/withMapboxToken/MapboxToken"
+import axios from "axios"
 import { SessionInfo } from "lib/SessionInfo"
-import fetchMock from "fetch-mock"
+import AxiosMockAdapter from "axios-mock-adapter"
+import { MapboxToken, TOKENS_URL } from "hocs/withMapboxToken/MapboxToken"
 
-function setSessionInfoWithMapboxToken(userMapboxToken: string): void {
+function setSessionInfo(
+  userMapboxToken: string,
+  commandLine = "streamlit hello"
+): void {
   SessionInfo.current = new SessionInfo({
     sessionId: "mockSessionId",
     streamlitVersion: "sv",
@@ -27,31 +31,36 @@ function setSessionInfoWithMapboxToken(userMapboxToken: string): void {
     installationId: "iid",
     authorEmail: "ae",
     maxCachedMessageAge: 2,
-    commandLine: "cl",
-    userMapboxToken: userMapboxToken,
+    commandLine,
+    userMapboxToken,
   })
 }
 
 describe("MapboxToken", () => {
+  let axiosMock: AxiosMockAdapter
+
   beforeEach(() => {
-    setSessionInfoWithMapboxToken("")
+    window.location.hostname = "localhost"
+    axiosMock = new AxiosMockAdapter(axios)
+    setSessionInfo("")
   })
 
   afterEach(() => {
-    SessionInfo["singleton"] = undefined
+    axiosMock.restore()
     MapboxToken["token"] = undefined
-    fetchMock.restore()
+    SessionInfo["singleton"] = undefined
   })
 
   test("Returns cached token if defined", async () => {
     MapboxToken["token"] = "cached"
+
     await expect(MapboxToken.get()).resolves.toEqual("cached")
   })
 
   test("Returns userMapboxToken if non-empty", async () => {
     const userToken = "nonEmptyToken"
 
-    setSessionInfoWithMapboxToken(userToken)
+    setSessionInfo(userToken)
     await expect(MapboxToken.get()).resolves.toEqual(userToken)
 
     // The token should also be cached.
@@ -61,7 +70,8 @@ describe("MapboxToken", () => {
   test("Fetches remote token if userMapboxToken is empty", async () => {
     const remoteToken = "remoteMapboxToken"
 
-    fetchMock.get(TOKENS_URL, { mapbox: remoteToken })
+    axiosMock.onGet(TOKENS_URL).reply(200, { "mapbox-localhost": remoteToken })
+
     await expect(MapboxToken.get()).resolves.toEqual(remoteToken)
 
     // The token should also be cached.
@@ -69,7 +79,7 @@ describe("MapboxToken", () => {
   })
 
   test("Errors if remote token is missing", async () => {
-    fetchMock.get(TOKENS_URL, { ohNo: "noTokenHere" })
+    axiosMock.onGet(TOKENS_URL).replyOnce(200, { ohNo: "noTokenHere" })
 
     await expect(MapboxToken.get()).rejects.toEqual(
       new Error(`Missing token "mapbox" (${TOKENS_URL})`)
@@ -78,13 +88,26 @@ describe("MapboxToken", () => {
     // No cached token after failure.
     expect(MapboxToken["token"]).toBeUndefined()
 
-    fetchMock.restore()
-    fetchMock.get(TOKENS_URL, 404)
+    axiosMock.onGet(TOKENS_URL).replyOnce(404, {})
     await expect(MapboxToken.get()).rejects.toEqual(
-      new Error(`Bad status: 404 (${TOKENS_URL})`)
+      new Error(`Request failed with status code 404 (${TOKENS_URL})`)
     )
 
     // No cached token after failure.
     expect(MapboxToken["token"]).toBeUndefined()
   })
+
+  it("Errors if not localhost and missing token", async () => {
+    delete window.location
+    window.location = { hostname: "http://streamlit.io" } as Location
+    setSessionInfo("")
+
+    await expect(MapboxToken.get()).rejects.toMatchSnapshot()
+  })
+
+  it("Errors if not hello.py and missing token", async () => {
+    setSessionInfo("", "streamlit run example.py")
+
+    await expect(MapboxToken.get()).rejects.toMatchSnapshot()
+  })
 })
diff --git a/frontend/src/hocs/withMapboxToken/MapboxToken.ts b/frontend/src/hocs/withMapboxToken/MapboxToken.ts
index 39604cb26eb0..3cfd6ce5e31a 100644
--- a/frontend/src/hocs/withMapboxToken/MapboxToken.ts
+++ b/frontend/src/hocs/withMapboxToken/MapboxToken.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 
+import axios from "axios"
 import { SessionInfo } from "lib/SessionInfo"
 
 /**
@@ -25,6 +26,12 @@ export const TOKENS_URL = "https://data.streamlit.io/tokens.json"
 export class MapboxToken {
   private static token?: string
 
+  private static isItRunningLocal = (): boolean => {
+    const { hostname } = window.location
+
+    return hostname === "localhost" || hostname === "127.0.0.1"
+  }
+
   /**
    * Expose a singleton MapboxToken:
    * - If the user specified a token in their streamlit config, return it.
@@ -34,11 +41,29 @@ export class MapboxToken {
    * only be fetched once per session.)
    */
   public static async get(): Promise<string> {
-    if (MapboxToken.token == null) {
+    if (!MapboxToken.token) {
       if (SessionInfo.current.userMapboxToken !== "") {
         MapboxToken.token = SessionInfo.current.userMapboxToken
       } else {
-        MapboxToken.token = await this.fetchToken(TOKENS_URL, "mapbox")
+        const { commandLine } = SessionInfo.current
+
+        if (
+          this.isItRunningLocal() &&
+          commandLine.toLowerCase() === "streamlit hello"
+        ) {
+          MapboxToken.token = await this.fetchToken(TOKENS_URL, "mapbox")
+        } else {
+          throw new Error(
+            `
+            To use this you'll need a Mapbox access token. Please add it to your config.
+            
+            To get a token for yourself, create an account at
+            <a href="https://mapbox.com">https://mapbox.com</a>. It's free (for moderate usage levels)! See
+            <a href="https://docs.streamlit.io/cli.html#view-all-config-options">our documentation</a> for more
+            info on how to set config options.
+            `
+          )
+        }
       }
     }
 
@@ -49,26 +74,17 @@ export class MapboxToken {
     url: string,
     tokenName: string
   ): Promise<string> {
-    let rsp: Response
     try {
-      rsp = await fetch(url)
-    } catch (e) {
-      // Fetch error messages are abysmal, and give virtually no useful
-      // context. Catch errors and append the offending URL to their messages
-      // to make them a bit more useful.
-      throw new Error(`${e.message} (${url})`)
-    }
+      const response = await axios.get(url)
+      const { "mapbox-localhost": token } = response.data
 
-    if (!rsp.ok) {
-      throw new Error(`Bad status: ${rsp.status} (${url})`)
-    }
+      if (token == null || token === "") {
+        throw new Error(`Missing token "${tokenName}"`)
+      }
 
-    const json = await rsp.json()
-    const token = json[tokenName]
-    if (token == null || token === "") {
-      throw new Error(`Missing token "${tokenName}" (${url})`)
+      return token
+    } catch (e) {
+      throw new Error(`${e.message} (${url})`)
     }
-
-    return token
   }
 }
diff --git a/frontend/src/hocs/withMapboxToken/__snapshots__/MapboxToken.test.ts.snap b/frontend/src/hocs/withMapboxToken/__snapshots__/MapboxToken.test.ts.snap
new file mode 100644
index 000000000000..a8d7d44f8567
--- /dev/null
+++ b/frontend/src/hocs/withMapboxToken/__snapshots__/MapboxToken.test.ts.snap
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`MapboxToken Errors if not hello.py and missing token 1`] = `
+[Error: 
+            To use this you'll need a Mapbox access token. Please add it to your config.
+            
+            To get a token for yourself, create an account at
+            <a href="https://mapbox.com">https://mapbox.com</a>. It's free (for moderate usage levels)! See
+            <a href="https://docs.streamlit.io/cli.html#view-all-config-options">our documentation</a> for more
+            info on how to set config options.
+            ]
+`;
+
+exports[`MapboxToken Errors if not localhost and missing token 1`] = `
+[Error: 
+            To use this you'll need a Mapbox access token. Please add it to your config.
+            
+            To get a token for yourself, create an account at
+            <a href="https://mapbox.com">https://mapbox.com</a>. It's free (for moderate usage levels)! See
+            <a href="https://docs.streamlit.io/cli.html#view-all-config-options">our documentation</a> for more
+            info on how to set config options.
+            ]
+`;
diff --git a/frontend/src/hocs/withMapboxToken/withMapboxToken.test.tsx b/frontend/src/hocs/withMapboxToken/withMapboxToken.test.tsx
index cfacee473cd7..4de6f7015bd5 100644
--- a/frontend/src/hocs/withMapboxToken/withMapboxToken.test.tsx
+++ b/frontend/src/hocs/withMapboxToken/withMapboxToken.test.tsx
@@ -35,7 +35,7 @@ function waitOneTick(): Promise<void> {
 }
 
 describe("withMapboxToken", () => {
-  function getProps(): any {
+  function getProps(): object {
     return { label: "label" }
   }
 
diff --git a/frontend/src/hocs/withMapboxToken/withMapboxToken.tsx b/frontend/src/hocs/withMapboxToken/withMapboxToken.tsx
index d7b28bc79d09..fef3b602a9cc 100644
--- a/frontend/src/hocs/withMapboxToken/withMapboxToken.tsx
+++ b/frontend/src/hocs/withMapboxToken/withMapboxToken.tsx
@@ -29,6 +29,7 @@ interface Props {
 interface State {
   mapboxToken?: string
   mapboxTokenError?: Error
+  isFetching: boolean
 }
 
 /**
@@ -47,50 +48,64 @@ function withMapboxToken(
       super(props)
 
       this.state = {
+        isFetching: true,
         mapboxToken: undefined,
         mapboxTokenError: undefined,
       }
+
       this.initMapboxToken()
     }
 
     /**
      * Fetch our MapboxToken.
      */
-    private initMapboxToken = (): void => {
-      MapboxToken.get()
-        .then(token => this.setState({ mapboxToken: token }))
-        .catch(error => this.setState({ mapboxTokenError: error }))
+    private initMapboxToken = async (): Promise<void> => {
+      try {
+        const mapboxToken = await MapboxToken.get()
+
+        this.setState({
+          mapboxToken,
+          isFetching: false,
+        })
+      } catch (error) {
+        this.setState({
+          mapboxTokenError: error,
+          isFetching: false,
+        })
+      }
     }
 
     public render = (): JSX.Element => {
+      const { mapboxToken, mapboxTokenError, isFetching } = this.state
+      const { width } = this.props
+
       // We got an error when fetching our mapbox token: show the error.
-      if (this.state.mapboxTokenError != null) {
+      if (mapboxTokenError) {
+        const { message } = mapboxTokenError
+        const messageWithHTML = (
+          <div dangerouslySetInnerHTML={{ __html: message }} />
+        )
         return (
           <ErrorElement
-            width={this.props.width}
+            width={width}
             name="Error fetching Mapbox token"
-            message={this.state.mapboxTokenError.message}
+            message={messageWithHTML}
           />
         )
       }
 
       // If our mapboxToken hasn't been retrieved yet, show a loading alert.
-      if (this.state.mapboxToken === undefined) {
+      if (isFetching) {
         return (
           <Alert
             element={makeElementWithInfoText("Loading...").get("alert")}
-            width={this.props.width}
+            width={width}
           />
         )
       }
 
       // We have the mapbox token. Pass it through to our component.
-      return (
-        <WrappedComponent
-          mapboxToken={this.state.mapboxToken}
-          {...this.props}
-        />
-      )
+      return <WrappedComponent mapboxToken={mapboxToken} {...this.props} />
     }
   }
 
diff --git a/lib/streamlit/config.py b/lib/streamlit/config.py
index b0eff7c60a6e..457a9fdf1382 100644
--- a/lib/streamlit/config.py
+++ b/lib/streamlit/config.py
@@ -537,11 +537,9 @@ def _browser_server_port():
 _create_option(
     "mapbox.token",
     description="""Configure Streamlit to use a custom Mapbox
-                token for elements like st.deck_gl_chart and st.map. If you
-                don't do this you'll be using Streamlit's own token,
-                which has limitations and is not guaranteed to always work.
+                token for elements like st.deck_gl_chart and st.map.
                 To get a token for yourself, create an account at
-                https://mapbox.com. It's free! (for moderate usage levels)""",
+                https://mapbox.com. It's free (for moderate usage levels)!""",
     default_val="",
 )
 


Code review comments:
diff_hunk: @@ -47,50 +48,64 @@ function withMapboxToken(
       super(props)
 
       this.state = {
+        isFetching: true,
         mapboxToken: undefined,
         mapboxTokenError: undefined,
       }
+
       this.initMapboxToken()
     }
 
     /**
      * Fetch our MapboxToken.
      */
-    private initMapboxToken = (): void => {
-      MapboxToken.get()
-        .then(token => this.setState({ mapboxToken: token }))
-        .catch(error => this.setState({ mapboxTokenError: error }))
+    private initMapboxToken = async (): Promise<void> => {
+      try {
+        const mapboxToken = await MapboxToken.get()
+
+        this.setState({
+          mapboxToken,
+          isFetching: false,
+        })
+      } catch (error) {
+        this.setState({
+          mapboxTokenError: error,
+          isFetching: false,
+        })
+      }
     }
 
     public render = (): JSX.Element => {
+      const { mapboxToken, mapboxTokenError, isFetching } = this.state
+      const { width } = this.props
+
       // We got an error when fetching our mapbox token: show the error.
-      if (this.state.mapboxTokenError != null) {
+      if (mapboxTokenError) {
+        const { message } = mapboxTokenError
+        const messageWithHTML = (
+          <div dangerouslySetInnerHTML={{ __html: message }} />
path: frontend/src/hocs/withMapboxToken/withMapboxToken.tsx
text: @user1:
Let's not do `dangerouslySetInnerHTML`. This opens a possible security hole.

We could just throw a well-known error class, then render the error using JSX in here instead:

```
// In MapboxToken.ts
class MapboxFetchError extends Error {}
(...)
throw new MapboxFetchError()

// In withMapboxToken.tsx
if (mapboxTokenError instanceof MapboxFetchError) {
  messageWithHTML = (
    <div>Any <strong>JSX</strong> you want</div>
  )
} else {
  messageWithHTML = (
    <div>Unexpected error: { mapboxTokenError.message }</div>
  )
}
```