We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
gravatar,initials doesn't fallback when gravatar.com is unreachable
**Describe the bug**
On System -> Settings -> Avatar section, there is this blurb of text:
`Multiple values can be set, comma-separated, and authentik will fallback to the next mode when no avatar could be found. For example, setting this to gravatar,initials will attempt to get an avatar from Gravatar, and if the user has not configured on there, it will fallback to a generated avatar.`

However, if authentik is deployed in an environment that doesn't allow a network connection to gravatar.com, i.e. firewall or similar, even the setting "gravatar,initials" leaves each user's avatar as a broken thumbnail, and initials are never retrieved. Further, each GET of a user ends up being really slow. i.e. retrieving 150k users by paginating through /core/users/ takes 12 hours. 

Once changing the setting to "initials" only, retrieving 150k users takes about 2 minutes, and users have proper initials avatars.

**To Reproduce**
Set global Avatar setting to gravatar,initials and block outgoing connections to gravatar.com

**Expected behavior**
Failback to using initials setting

**Version and Deployment (please complete the following information):**

-   authentik version: [e.g. 2024.2.2]
-   Deployment: [e.g. custom helm]


--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `authentik/lib/avatars.py`
Content:
```
1 """Avatar utils"""
2 
3 from base64 import b64encode
4 from functools import cache as funccache
5 from hashlib import md5
6 from typing import TYPE_CHECKING
7 from urllib.parse import urlencode
8 
9 from django.core.cache import cache
10 from django.http import HttpRequest, HttpResponseNotFound
11 from django.templatetags.static import static
12 from lxml import etree  # nosec
13 from lxml.etree import Element, SubElement  # nosec
14 from requests.exceptions import RequestException
15 
16 from authentik.lib.config import get_path_from_dict
17 from authentik.lib.utils.http import get_http_session
18 from authentik.tenants.utils import get_current_tenant
19 
20 if TYPE_CHECKING:
21     from authentik.core.models import User
22 
23 GRAVATAR_URL = "https://secure.gravatar.com"
24 DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
25 CACHE_KEY_GRAVATAR = "goauthentik.io/lib/avatars/"
26 
27 SVG_XML_NS = "http://www.w3.org/2000/svg"
28 SVG_NS_MAP = {None: SVG_XML_NS}
29 # Match fonts used in web UI
30 SVG_FONTS = [
31     "'RedHatText'",
32     "'Overpass'",
33     "overpass",
34     "helvetica",
35     "arial",
36     "sans-serif",
37 ]
38 
39 
40 def avatar_mode_none(user: "User", mode: str) -> str | None:
41     """No avatar"""
42     return DEFAULT_AVATAR
43 
44 
45 def avatar_mode_attribute(user: "User", mode: str) -> str | None:
46     """Avatars based on a user attribute"""
47     avatar = get_path_from_dict(user.attributes, mode[11:], default=None)
48     return avatar
49 
50 
51 def avatar_mode_gravatar(user: "User", mode: str) -> str | None:
52     """Gravatar avatars"""
53     # gravatar uses md5 for their URLs, so md5 can't be avoided
54     mail_hash = md5(user.email.lower().encode("utf-8")).hexdigest()  # nosec
55     parameters = [("size", "158"), ("rating", "g"), ("default", "404")]
56     gravatar_url = f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}"
57 
58     full_key = CACHE_KEY_GRAVATAR + mail_hash
59     if cache.has_key(full_key):
60         cache.touch(full_key)
61         return cache.get(full_key)
62 
63     try:
64         # Since we specify a default of 404, do a HEAD request
65         # (HEAD since we don't need the body)
66         # so if that returns a 404, move onto the next mode
67         res = get_http_session().head(gravatar_url, timeout=5)
68         if res.status_code == HttpResponseNotFound.status_code:
69             cache.set(full_key, None)
70             return None
71         res.raise_for_status()
72     except RequestException:
73         return gravatar_url
74     cache.set(full_key, gravatar_url)
75     return gravatar_url
76 
77 
78 def generate_colors(text: str) -> tuple[str, str]:
79     """Generate colours based on `text`"""
80     color = int(md5(text.lower().encode("utf-8")).hexdigest(), 16) % 0xFFFFFF  # nosec
81 
82     # Get a (somewhat arbitrarily) reduced scope of colors
83     # to avoid too dark or light backgrounds
84     blue = min(max((color) & 0xFF, 55), 200)
85     green = min(max((color >> 8) & 0xFF, 55), 200)
86     red = min(max((color >> 16) & 0xFF, 55), 200)
87     bg_hex = f"{red:02x}{green:02x}{blue:02x}"
88     # Contrasting text color (https://stackoverflow.com/a/3943023)
89     text_hex = (
90         "000" if (red * 0.299 + green * 0.587 + blue * 0.114) > 186 else "fff"  # noqa: PLR2004
91     )
92     return bg_hex, text_hex
93 
94 
95 @funccache
96 def generate_avatar_from_name(
97     name: str,
98     length: int = 2,
99     size: int = 64,
100     rounded: bool = False,
101     font_size: float = 0.4375,
102     bold: bool = False,
103     uppercase: bool = True,
104 ) -> str:
105     """ "Generate an avatar with initials in SVG format.
106 
107     Inspired from: https://github.com/LasseRafn/ui-avatars
108     """
109     name_parts = name.split()
110     # Only abbreviate first and last name
111     if len(name_parts) > 2:  # noqa: PLR2004
112         name_parts = [name_parts[0], name_parts[-1]]
113 
114     if len(name_parts) == 1:
115         initials = name_parts[0][:length]
116     else:
117         initials = "".join([part[0] for part in name_parts[:-1]])
118         initials += name_parts[-1]
119         initials = initials[:length]
120 
121     bg_hex, text_hex = generate_colors(name)
122 
123     half_size = size // 2
124     shape = "circle" if rounded else "rect"
125     font_weight = "600" if bold else "400"
126 
127     root_element: Element = Element(f"{{{SVG_XML_NS}}}svg", nsmap=SVG_NS_MAP)
128     root_element.attrib["width"] = f"{size}px"
129     root_element.attrib["height"] = f"{size}px"
130     root_element.attrib["viewBox"] = f"0 0 {size} {size}"
131     root_element.attrib["version"] = "1.1"
132 
133     shape = SubElement(root_element, f"{{{SVG_XML_NS}}}{shape}", nsmap=SVG_NS_MAP)
134     shape.attrib["fill"] = f"#{bg_hex}"
135     shape.attrib["cx"] = f"{half_size}"
136     shape.attrib["cy"] = f"{half_size}"
137     shape.attrib["width"] = f"{size}"
138     shape.attrib["height"] = f"{size}"
139     shape.attrib["r"] = f"{half_size}"
140 
141     text = SubElement(root_element, f"{{{SVG_XML_NS}}}text", nsmap=SVG_NS_MAP)
142     text.attrib["x"] = "50%"
143     text.attrib["y"] = "50%"
144     text.attrib["style"] = (
145         f"color: #{text_hex}; " "line-height: 1; " f"font-family: {','.join(SVG_FONTS)}; "
146     )
147     text.attrib["fill"] = f"#{text_hex}"
148     text.attrib["alignment-baseline"] = "middle"
149     text.attrib["dominant-baseline"] = "middle"
150     text.attrib["text-anchor"] = "middle"
151     text.attrib["font-size"] = f"{round(size * font_size)}"
152     text.attrib["font-weight"] = f"{font_weight}"
153     text.attrib["dy"] = ".1em"
154     text.text = initials if not uppercase else initials.upper()
155 
156     return etree.tostring(root_element).decode()
157 
158 
159 def avatar_mode_generated(user: "User", mode: str) -> str | None:
160     """Wrapper that converts generated avatar to base64 svg"""
161     # By default generate based off of user's display name
162     name = user.name.strip()
163     if name == "":
164         # Fallback to username
165         name = user.username.strip()
166     # If we still don't have anything, fallback to `a k`
167     if name == "":
168         name = "a k"
169     svg = generate_avatar_from_name(name)
170     return f"data:image/svg+xml;base64,{b64encode(svg.encode('utf-8')).decode('utf-8')}"
171 
172 
173 def avatar_mode_url(user: "User", mode: str) -> str | None:
174     """Format url"""
175     mail_hash = md5(user.email.lower().encode("utf-8")).hexdigest()  # nosec
176     return mode % {
177         "username": user.username,
178         "mail_hash": mail_hash,
179         "upn": user.attributes.get("upn", ""),
180     }
181 
182 
183 def get_avatar(user: "User", request: HttpRequest | None = None) -> str:
184     """Get avatar with configured mode"""
185     mode_map = {
186         "none": avatar_mode_none,
187         "initials": avatar_mode_generated,
188         "gravatar": avatar_mode_gravatar,
189     }
190     tenant = None
191     if request:
192         tenant = request.tenant
193     else:
194         tenant = get_current_tenant()
195     modes: str = tenant.avatars
196     for mode in modes.split(","):
197         avatar = None
198         if mode in mode_map:
199             avatar = mode_map[mode](user, mode)
200         elif mode.startswith("attributes."):
201             avatar = avatar_mode_attribute(user, mode)
202         elif "://" in mode:
203             avatar = avatar_mode_url(user, mode)
204         if avatar:
205             return avatar
206     return avatar_mode_none(user, modes)
207
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/authentik/lib/avatars.py b/authentik/lib/avatars.py
--- a/authentik/lib/avatars.py
+++ b/authentik/lib/avatars.py
@@ -11,7 +11,7 @@
 from django.templatetags.static import static
 from lxml import etree  # nosec
 from lxml.etree import Element, SubElement  # nosec
-from requests.exceptions import RequestException
+from requests.exceptions import ConnectionError, HTTPError, RequestException, Timeout
 
 from authentik.lib.config import get_path_from_dict
 from authentik.lib.utils.http import get_http_session
@@ -23,6 +23,8 @@
 GRAVATAR_URL = "https://secure.gravatar.com"
 DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
 CACHE_KEY_GRAVATAR = "goauthentik.io/lib/avatars/"
+CACHE_KEY_GRAVATAR_AVAILABLE = "goauthentik.io/lib/avatars/gravatar_available"
+GRAVATAR_STATUS_TTL_SECONDS = 60 * 60 * 8  # 8 Hours
 
 SVG_XML_NS = "http://www.w3.org/2000/svg"
 SVG_NS_MAP = {None: SVG_XML_NS}
@@ -50,6 +52,9 @@
 
 def avatar_mode_gravatar(user: "User", mode: str) -> str | None:
     """Gravatar avatars"""
+    if not cache.get(CACHE_KEY_GRAVATAR_AVAILABLE, True):
+        return None
+
     # gravatar uses md5 for their URLs, so md5 can't be avoided
     mail_hash = md5(user.email.lower().encode("utf-8")).hexdigest()  # nosec
     parameters = [("size", "158"), ("rating", "g"), ("default", "404")]
@@ -69,6 +74,8 @@
             cache.set(full_key, None)
             return None
         res.raise_for_status()
+    except (Timeout, ConnectionError, HTTPError):
+        cache.set(CACHE_KEY_GRAVATAR_AVAILABLE, False, timeout=GRAVATAR_STATUS_TTL_SECONDS)
     except RequestException:
         return gravatar_url
     cache.set(full_key, gravatar_url)

{"golden_diff": "diff --git a/authentik/lib/avatars.py b/authentik/lib/avatars.py\n--- a/authentik/lib/avatars.py\n+++ b/authentik/lib/avatars.py\n@@ -11,7 +11,7 @@\n from django.templatetags.static import static\n from lxml import etree  # nosec\n from lxml.etree import Element, SubElement  # nosec\n-from requests.exceptions import RequestException\n+from requests.exceptions import ConnectionError, HTTPError, RequestException, Timeout\n \n from authentik.lib.config import get_path_from_dict\n from authentik.lib.utils.http import get_http_session\n@@ -23,6 +23,8 @@\n GRAVATAR_URL = \"https://secure.gravatar.com\"\n DEFAULT_AVATAR = static(\"dist/assets/images/user_default.png\")\n CACHE_KEY_GRAVATAR = \"goauthentik.io/lib/avatars/\"\n+CACHE_KEY_GRAVATAR_AVAILABLE = \"goauthentik.io/lib/avatars/gravatar_available\"\n+GRAVATAR_STATUS_TTL_SECONDS = 60 * 60 * 8  # 8 Hours\n \n SVG_XML_NS = \"http://www.w3.org/2000/svg\"\n SVG_NS_MAP = {None: SVG_XML_NS}\n@@ -50,6 +52,9 @@\n \n def avatar_mode_gravatar(user: \"User\", mode: str) -> str | None:\n     \"\"\"Gravatar avatars\"\"\"\n+    if not cache.get(CACHE_KEY_GRAVATAR_AVAILABLE, True):\n+        return None\n+\n     # gravatar uses md5 for their URLs, so md5 can't be avoided\n     mail_hash = md5(user.email.lower().encode(\"utf-8\")).hexdigest()  # nosec\n     parameters = [(\"size\", \"158\"), (\"rating\", \"g\"), (\"default\", \"404\")]\n@@ -69,6 +74,8 @@\n             cache.set(full_key, None)\n             return None\n         res.raise_for_status()\n+    except (Timeout, ConnectionError, HTTPError):\n+        cache.set(CACHE_KEY_GRAVATAR_AVAILABLE, False, timeout=GRAVATAR_STATUS_TTL_SECONDS)\n     except RequestException:\n         return gravatar_url\n     cache.set(full_key, gravatar_url)\n", "issue": "gravatar,initials doesn't fallback when gravatar.com is unreachable\n**Describe the bug**\r\nOn System -> Settings -> Avatar section, there is this blurb of text:\r\n`Multiple values can be set, comma-separated, and authentik will fallback to the next mode when no avatar could be found. For example, setting this to gravatar,initials will attempt to get an avatar from Gravatar, and if the user has not configured on there, it will fallback to a generated avatar.`\r\n\r\nHowever, if authentik is deployed in an environment that doesn't allow a network connection to gravatar.com, i.e. firewall or similar, even the setting \"gravatar,initials\" leaves each user's avatar as a broken thumbnail, and initials are never retrieved. Further, each GET of a user ends up being really slow. i.e. retrieving 150k users by paginating through /core/users/ takes 12 hours. \r\n\r\nOnce changing the setting to \"initials\" only, retrieving 150k users takes about 2 minutes, and users have proper initials avatars.\r\n\r\n**To Reproduce**\r\nSet global Avatar setting to gravatar,initials and block outgoing connections to gravatar.com\r\n\r\n**Expected behavior**\r\nFailback to using initials setting\r\n\r\n**Version and Deployment (please complete the following information):**\r\n\r\n-   authentik version: [e.g. 2024.2.2]\r\n-   Deployment: [e.g. custom helm]\r\n\n", "before_files": [{"content": "\"\"\"Avatar utils\"\"\"\n\nfrom base64 import b64encode\nfrom functools import cache as funccache\nfrom hashlib import md5\nfrom typing import TYPE_CHECKING\nfrom urllib.parse import urlencode\n\nfrom django.core.cache import cache\nfrom django.http import HttpRequest, HttpResponseNotFound\nfrom django.templatetags.static import static\nfrom lxml import etree  # nosec\nfrom lxml.etree import Element, SubElement  # nosec\nfrom requests.exceptions import RequestException\n\nfrom authentik.lib.config import get_path_from_dict\nfrom authentik.lib.utils.http import get_http_session\nfrom authentik.tenants.utils import get_current_tenant\n\nif TYPE_CHECKING:\n    from authentik.core.models import User\n\nGRAVATAR_URL = \"https://secure.gravatar.com\"\nDEFAULT_AVATAR = static(\"dist/assets/images/user_default.png\")\nCACHE_KEY_GRAVATAR = \"goauthentik.io/lib/avatars/\"\n\nSVG_XML_NS = \"http://www.w3.org/2000/svg\"\nSVG_NS_MAP = {None: SVG_XML_NS}\n# Match fonts used in web UI\nSVG_FONTS = [\n    \"'RedHatText'\",\n    \"'Overpass'\",\n    \"overpass\",\n    \"helvetica\",\n    \"arial\",\n    \"sans-serif\",\n]\n\n\ndef avatar_mode_none(user: \"User\", mode: str) -> str | None:\n    \"\"\"No avatar\"\"\"\n    return DEFAULT_AVATAR\n\n\ndef avatar_mode_attribute(user: \"User\", mode: str) -> str | None:\n    \"\"\"Avatars based on a user attribute\"\"\"\n    avatar = get_path_from_dict(user.attributes, mode[11:], default=None)\n    return avatar\n\n\ndef avatar_mode_gravatar(user: \"User\", mode: str) -> str | None:\n    \"\"\"Gravatar avatars\"\"\"\n    # gravatar uses md5 for their URLs, so md5 can't be avoided\n    mail_hash = md5(user.email.lower().encode(\"utf-8\")).hexdigest()  # nosec\n    parameters = [(\"size\", \"158\"), (\"rating\", \"g\"), (\"default\", \"404\")]\n    gravatar_url = f\"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}\"\n\n    full_key = CACHE_KEY_GRAVATAR + mail_hash\n    if cache.has_key(full_key):\n        cache.touch(full_key)\n        return cache.get(full_key)\n\n    try:\n        # Since we specify a default of 404, do a HEAD request\n        # (HEAD since we don't need the body)\n        # so if that returns a 404, move onto the next mode\n        res = get_http_session().head(gravatar_url, timeout=5)\n        if res.status_code == HttpResponseNotFound.status_code:\n            cache.set(full_key, None)\n            return None\n        res.raise_for_status()\n    except RequestException:\n        return gravatar_url\n    cache.set(full_key, gravatar_url)\n    return gravatar_url\n\n\ndef generate_colors(text: str) -> tuple[str, str]:\n    \"\"\"Generate colours based on `text`\"\"\"\n    color = int(md5(text.lower().encode(\"utf-8\")).hexdigest(), 16) % 0xFFFFFF  # nosec\n\n    # Get a (somewhat arbitrarily) reduced scope of colors\n    # to avoid too dark or light backgrounds\n    blue = min(max((color) & 0xFF, 55), 200)\n    green = min(max((color >> 8) & 0xFF, 55), 200)\n    red = min(max((color >> 16) & 0xFF, 55), 200)\n    bg_hex = f\"{red:02x}{green:02x}{blue:02x}\"\n    # Contrasting text color (https://stackoverflow.com/a/3943023)\n    text_hex = (\n        \"000\" if (red * 0.299 + green * 0.587 + blue * 0.114) > 186 else \"fff\"  # noqa: PLR2004\n    )\n    return bg_hex, text_hex\n\n\n@funccache\ndef generate_avatar_from_name(\n    name: str,\n    length: int = 2,\n    size: int = 64,\n    rounded: bool = False,\n    font_size: float = 0.4375,\n    bold: bool = False,\n    uppercase: bool = True,\n) -> str:\n    \"\"\" \"Generate an avatar with initials in SVG format.\n\n    Inspired from: https://github.com/LasseRafn/ui-avatars\n    \"\"\"\n    name_parts = name.split()\n    # Only abbreviate first and last name\n    if len(name_parts) > 2:  # noqa: PLR2004\n        name_parts = [name_parts[0], name_parts[-1]]\n\n    if len(name_parts) == 1:\n        initials = name_parts[0][:length]\n    else:\n        initials = \"\".join([part[0] for part in name_parts[:-1]])\n        initials += name_parts[-1]\n        initials = initials[:length]\n\n    bg_hex, text_hex = generate_colors(name)\n\n    half_size = size // 2\n    shape = \"circle\" if rounded else \"rect\"\n    font_weight = \"600\" if bold else \"400\"\n\n    root_element: Element = Element(f\"{{{SVG_XML_NS}}}svg\", nsmap=SVG_NS_MAP)\n    root_element.attrib[\"width\"] = f\"{size}px\"\n    root_element.attrib[\"height\"] = f\"{size}px\"\n    root_element.attrib[\"viewBox\"] = f\"0 0 {size} {size}\"\n    root_element.attrib[\"version\"] = \"1.1\"\n\n    shape = SubElement(root_element, f\"{{{SVG_XML_NS}}}{shape}\", nsmap=SVG_NS_MAP)\n    shape.attrib[\"fill\"] = f\"#{bg_hex}\"\n    shape.attrib[\"cx\"] = f\"{half_size}\"\n    shape.attrib[\"cy\"] = f\"{half_size}\"\n    shape.attrib[\"width\"] = f\"{size}\"\n    shape.attrib[\"height\"] = f\"{size}\"\n    shape.attrib[\"r\"] = f\"{half_size}\"\n\n    text = SubElement(root_element, f\"{{{SVG_XML_NS}}}text\", nsmap=SVG_NS_MAP)\n    text.attrib[\"x\"] = \"50%\"\n    text.attrib[\"y\"] = \"50%\"\n    text.attrib[\"style\"] = (\n        f\"color: #{text_hex}; \" \"line-height: 1; \" f\"font-family: {','.join(SVG_FONTS)}; \"\n    )\n    text.attrib[\"fill\"] = f\"#{text_hex}\"\n    text.attrib[\"alignment-baseline\"] = \"middle\"\n    text.attrib[\"dominant-baseline\"] = \"middle\"\n    text.attrib[\"text-anchor\"] = \"middle\"\n    text.attrib[\"font-size\"] = f\"{round(size * font_size)}\"\n    text.attrib[\"font-weight\"] = f\"{font_weight}\"\n    text.attrib[\"dy\"] = \".1em\"\n    text.text = initials if not uppercase else initials.upper()\n\n    return etree.tostring(root_element).decode()\n\n\ndef avatar_mode_generated(user: \"User\", mode: str) -> str | None:\n    \"\"\"Wrapper that converts generated avatar to base64 svg\"\"\"\n    # By default generate based off of user's display name\n    name = user.name.strip()\n    if name == \"\":\n        # Fallback to username\n        name = user.username.strip()\n    # If we still don't have anything, fallback to `a k`\n    if name == \"\":\n        name = \"a k\"\n    svg = generate_avatar_from_name(name)\n    return f\"data:image/svg+xml;base64,{b64encode(svg.encode('utf-8')).decode('utf-8')}\"\n\n\ndef avatar_mode_url(user: \"User\", mode: str) -> str | None:\n    \"\"\"Format url\"\"\"\n    mail_hash = md5(user.email.lower().encode(\"utf-8\")).hexdigest()  # nosec\n    return mode % {\n        \"username\": user.username,\n        \"mail_hash\": mail_hash,\n        \"upn\": user.attributes.get(\"upn\", \"\"),\n    }\n\n\ndef get_avatar(user: \"User\", request: HttpRequest | None = None) -> str:\n    \"\"\"Get avatar with configured mode\"\"\"\n    mode_map = {\n        \"none\": avatar_mode_none,\n        \"initials\": avatar_mode_generated,\n        \"gravatar\": avatar_mode_gravatar,\n    }\n    tenant = None\n    if request:\n        tenant = request.tenant\n    else:\n        tenant = get_current_tenant()\n    modes: str = tenant.avatars\n    for mode in modes.split(\",\"):\n        avatar = None\n        if mode in mode_map:\n            avatar = mode_map[mode](user, mode)\n        elif mode.startswith(\"attributes.\"):\n            avatar = avatar_mode_attribute(user, mode)\n        elif \"://\" in mode:\n            avatar = avatar_mode_url(user, mode)\n        if avatar:\n            return avatar\n    return avatar_mode_none(user, modes)\n", "path": "authentik/lib/avatars.py"}], "after_files": [{"content": "\"\"\"Avatar utils\"\"\"\n\nfrom base64 import b64encode\nfrom functools import cache as funccache\nfrom hashlib import md5\nfrom typing import TYPE_CHECKING\nfrom urllib.parse import urlencode\n\nfrom django.core.cache import cache\nfrom django.http import HttpRequest, HttpResponseNotFound\nfrom django.templatetags.static import static\nfrom lxml import etree  # nosec\nfrom lxml.etree import Element, SubElement  # nosec\nfrom requests.exceptions import ConnectionError, HTTPError, RequestException, Timeout\n\nfrom authentik.lib.config import get_path_from_dict\nfrom authentik.lib.utils.http import get_http_session\nfrom authentik.tenants.utils import get_current_tenant\n\nif TYPE_CHECKING:\n    from authentik.core.models import User\n\nGRAVATAR_URL = \"https://secure.gravatar.com\"\nDEFAULT_AVATAR = static(\"dist/assets/images/user_default.png\")\nCACHE_KEY_GRAVATAR = \"goauthentik.io/lib/avatars/\"\nCACHE_KEY_GRAVATAR_AVAILABLE = \"goauthentik.io/lib/avatars/gravatar_available\"\nGRAVATAR_STATUS_TTL_SECONDS = 60 * 60 * 8  # 8 Hours\n\nSVG_XML_NS = \"http://www.w3.org/2000/svg\"\nSVG_NS_MAP = {None: SVG_XML_NS}\n# Match fonts used in web UI\nSVG_FONTS = [\n    \"'RedHatText'\",\n    \"'Overpass'\",\n    \"overpass\",\n    \"helvetica\",\n    \"arial\",\n    \"sans-serif\",\n]\n\n\ndef avatar_mode_none(user: \"User\", mode: str) -> str | None:\n    \"\"\"No avatar\"\"\"\n    return DEFAULT_AVATAR\n\n\ndef avatar_mode_attribute(user: \"User\", mode: str) -> str | None:\n    \"\"\"Avatars based on a user attribute\"\"\"\n    avatar = get_path_from_dict(user.attributes, mode[11:], default=None)\n    return avatar\n\n\ndef avatar_mode_gravatar(user: \"User\", mode: str) -> str | None:\n    \"\"\"Gravatar avatars\"\"\"\n    if not cache.get(CACHE_KEY_GRAVATAR_AVAILABLE, True):\n        return None\n\n    # gravatar uses md5 for their URLs, so md5 can't be avoided\n    mail_hash = md5(user.email.lower().encode(\"utf-8\")).hexdigest()  # nosec\n    parameters = [(\"size\", \"158\"), (\"rating\", \"g\"), (\"default\", \"404\")]\n    gravatar_url = f\"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}\"\n\n    full_key = CACHE_KEY_GRAVATAR + mail_hash\n    if cache.has_key(full_key):\n        cache.touch(full_key)\n        return cache.get(full_key)\n\n    try:\n        # Since we specify a default of 404, do a HEAD request\n        # (HEAD since we don't need the body)\n        # so if that returns a 404, move onto the next mode\n        res = get_http_session().head(gravatar_url, timeout=5)\n        if res.status_code == HttpResponseNotFound.status_code:\n            cache.set(full_key, None)\n            return None\n        res.raise_for_status()\n    except (Timeout, ConnectionError, HTTPError):\n        cache.set(CACHE_KEY_GRAVATAR_AVAILABLE, False, timeout=GRAVATAR_STATUS_TTL_SECONDS)\n    except RequestException:\n        return gravatar_url\n    cache.set(full_key, gravatar_url)\n    return gravatar_url\n\n\ndef generate_colors(text: str) -> tuple[str, str]:\n    \"\"\"Generate colours based on `text`\"\"\"\n    color = int(md5(text.lower().encode(\"utf-8\")).hexdigest(), 16) % 0xFFFFFF  # nosec\n\n    # Get a (somewhat arbitrarily) reduced scope of colors\n    # to avoid too dark or light backgrounds\n    blue = min(max((color) & 0xFF, 55), 200)\n    green = min(max((color >> 8) & 0xFF, 55), 200)\n    red = min(max((color >> 16) & 0xFF, 55), 200)\n    bg_hex = f\"{red:02x}{green:02x}{blue:02x}\"\n    # Contrasting text color (https://stackoverflow.com/a/3943023)\n    text_hex = (\n        \"000\" if (red * 0.299 + green * 0.587 + blue * 0.114) > 186 else \"fff\"  # noqa: PLR2004\n    )\n    return bg_hex, text_hex\n\n\n@funccache\ndef generate_avatar_from_name(\n    name: str,\n    length: int = 2,\n    size: int = 64,\n    rounded: bool = False,\n    font_size: float = 0.4375,\n    bold: bool = False,\n    uppercase: bool = True,\n) -> str:\n    \"\"\" \"Generate an avatar with initials in SVG format.\n\n    Inspired from: https://github.com/LasseRafn/ui-avatars\n    \"\"\"\n    name_parts = name.split()\n    # Only abbreviate first and last name\n    if len(name_parts) > 2:  # noqa: PLR2004\n        name_parts = [name_parts[0], name_parts[-1]]\n\n    if len(name_parts) == 1:\n        initials = name_parts[0][:length]\n    else:\n        initials = \"\".join([part[0] for part in name_parts[:-1]])\n        initials += name_parts[-1]\n        initials = initials[:length]\n\n    bg_hex, text_hex = generate_colors(name)\n\n    half_size = size // 2\n    shape = \"circle\" if rounded else \"rect\"\n    font_weight = \"600\" if bold else \"400\"\n\n    root_element: Element = Element(f\"{{{SVG_XML_NS}}}svg\", nsmap=SVG_NS_MAP)\n    root_element.attrib[\"width\"] = f\"{size}px\"\n    root_element.attrib[\"height\"] = f\"{size}px\"\n    root_element.attrib[\"viewBox\"] = f\"0 0 {size} {size}\"\n    root_element.attrib[\"version\"] = \"1.1\"\n\n    shape = SubElement(root_element, f\"{{{SVG_XML_NS}}}{shape}\", nsmap=SVG_NS_MAP)\n    shape.attrib[\"fill\"] = f\"#{bg_hex}\"\n    shape.attrib[\"cx\"] = f\"{half_size}\"\n    shape.attrib[\"cy\"] = f\"{half_size}\"\n    shape.attrib[\"width\"] = f\"{size}\"\n    shape.attrib[\"height\"] = f\"{size}\"\n    shape.attrib[\"r\"] = f\"{half_size}\"\n\n    text = SubElement(root_element, f\"{{{SVG_XML_NS}}}text\", nsmap=SVG_NS_MAP)\n    text.attrib[\"x\"] = \"50%\"\n    text.attrib[\"y\"] = \"50%\"\n    text.attrib[\"style\"] = (\n        f\"color: #{text_hex}; \" \"line-height: 1; \" f\"font-family: {','.join(SVG_FONTS)}; \"\n    )\n    text.attrib[\"fill\"] = f\"#{text_hex}\"\n    text.attrib[\"alignment-baseline\"] = \"middle\"\n    text.attrib[\"dominant-baseline\"] = \"middle\"\n    text.attrib[\"text-anchor\"] = \"middle\"\n    text.attrib[\"font-size\"] = f\"{round(size * font_size)}\"\n    text.attrib[\"font-weight\"] = f\"{font_weight}\"\n    text.attrib[\"dy\"] = \".1em\"\n    text.text = initials if not uppercase else initials.upper()\n\n    return etree.tostring(root_element).decode()\n\n\ndef avatar_mode_generated(user: \"User\", mode: str) -> str | None:\n    \"\"\"Wrapper that converts generated avatar to base64 svg\"\"\"\n    # By default generate based off of user's display name\n    name = user.name.strip()\n    if name == \"\":\n        # Fallback to username\n        name = user.username.strip()\n    # If we still don't have anything, fallback to `a k`\n    if name == \"\":\n        name = \"a k\"\n    svg = generate_avatar_from_name(name)\n    return f\"data:image/svg+xml;base64,{b64encode(svg.encode('utf-8')).decode('utf-8')}\"\n\n\ndef avatar_mode_url(user: \"User\", mode: str) -> str | None:\n    \"\"\"Format url\"\"\"\n    mail_hash = md5(user.email.lower().encode(\"utf-8\")).hexdigest()  # nosec\n    return mode % {\n        \"username\": user.username,\n        \"mail_hash\": mail_hash,\n        \"upn\": user.attributes.get(\"upn\", \"\"),\n    }\n\n\ndef get_avatar(user: \"User\", request: HttpRequest | None = None) -> str:\n    \"\"\"Get avatar with configured mode\"\"\"\n    mode_map = {\n        \"none\": avatar_mode_none,\n        \"initials\": avatar_mode_generated,\n        \"gravatar\": avatar_mode_gravatar,\n    }\n    tenant = None\n    if request:\n        tenant = request.tenant\n    else:\n        tenant = get_current_tenant()\n    modes: str = tenant.avatars\n    for mode in modes.split(\",\"):\n        avatar = None\n        if mode in mode_map:\n            avatar = mode_map[mode](user, mode)\n        elif mode.startswith(\"attributes.\"):\n            avatar = avatar_mode_attribute(user, mode)\n        elif \"://\" in mode:\n            avatar = avatar_mode_url(user, mode)\n        if avatar:\n            return avatar\n    return avatar_mode_none(user, modes)\n", "path": "authentik/lib/avatars.py"}]}