Repository: nautobot/nautobot

Issue:
Allow to customize CSS to differentiate multiple instances
### As ...

Patti - Platform Admin

### I want ...

to be able to customize the CSS (maybe the background-color but not just this) of a Nautobot instance

### So that ...

Users (operators, developpers, ..) are able to easily distinguish the different instances (dev, test, staging, prod, ...)

### I know this is done when...

* An option to input CSS is present in the admin section
* This option is overridable using settings.py (to allow copying data from an instance to another while keeping this property)
* The CSS is applied on every pages

Currently, the only option is to set a banner, but having a banner on each instance with the same blue color doesn't help to differentiate without reading the banner text.

### Optional - Feature groups this request pertains to.

- [ ] Automation
- [ ] Circuits
- [ ] DCIM
- [ ] IPAM
- [ ] Misc (including Data Sources)
- [ ] Organization
- [ ] Plugins (and other Extensibility)
- [ ] Security (Secrets, etc)
- [ ] Image Management
- [X] UI/UX
- [ ] Documentation
- [ ] Other (not directly a platform feature)

### Database Changes

* Add an option?

### External Dependencies

None ?


Discussion:
This is certainly not what you'll want to implement, but here's what I'm using to add a custom style (path to the files):

* `patcher.py`
```py
try:
  import nautobot, rest_framework
  nautobot_base_path = nautobot.__path__[0]
  rest_framework_base_path = rest_framework.__path__[0]
  del nautobot, rest_framework
except Exception:
  print('Unable to locate Nautobot or Django REST framework installation path')
  raise SystemExit(1)

templates = (
    (nautobot_base_path, 'core/templates/base.html'),
    (nautobot_base_path, 'core/templates/admin/base.html'),
    (nautobot_base_path, 'core/templates/graphene/graphiql.html'),
    (rest_framework_base_path, 'templates/rest_framework/base.html'),
    # Alternative: nautobot/core/templates/rest_framework/api.html
)
# Alternative: nautobot/core/templates/inc/media.html + graphql

where = '</head>'
patch = '''\
    {% if settings.MY_CUSTOM_STYLE %}
    <style>{{ settings.MY_CUSTOM_STYLE | safe }}</style>
    {% endif %}
'''  # '    {% if se..\n    <styl..\n    {% en..\n'

for pkg_path, template_path in templates:
    with open(f'{pkg_path}/{template_path}', 'r+') as fh:
        content = fh.read()
        content_len = len(content)
        content = content.replace(where, patch + where)

        if len(content) == content_len:
            print(f'Unable to patch file {fh.name!r}')
            raise SystemExit(1)

        fh.seek(0)
        fh.truncate()
        fh.write(content)

    print(f'Patched {pkg_path.split("/")[-1]}{template_path!r}')
```

* `settings.py`
```py
# .navbar-default, .footer { background: linear-gradient(to right, #f8f8f8, moccasin); }
if os.getenv('MY_CUSTOM_STYLE'):
    MY_CUSTOM_STYLE = os.getenv('MY_CUSTOM_STYLE').strip()
elif os.getenv('MY_CUSTOM_STYLE_FILE'):
    with open(
        os.getenv('MY_CUSTOM_STYLE_FILE'),
        mode='r',
        encoding='utf-8'
    ) as fh:
        MY_CUSTOM_STYLE = fh.read()
```
Brainstorming on a feature-set and requirements here:
- Given that we'll eventually want to set a content-security-policy in nautobot that disallows unsafe-inline, we probably don't want to add a feature that directly allows for injection of JS and CSS as a string. It'd be safer to instead provide a way to easily add another .js or .css file to `inc/media.html` and `inc/javascript.html`.
- One way to do this would be via Apps, as any App can bundle additional static-files which we could then cleanly reference without needing to modify any content-security-policy configuration (as would be required to allow resources to load from a different domain). Different instances could install different apps (or different versions of the same app) to provide the desired differentiaion.
- A less elegant approach would be to allow the specification of arbitrary URLs to load from as a part of the settings, but that will likely run afoul of content-security-policy restrictions.

Resolving patch:
diff --git a/nautobot/core/settings.py b/nautobot/core/settings.py
--- a/nautobot/core/settings.py
+++ b/nautobot/core/settings.py
@@ -69,7 +69,7 @@
     "xmpp",
 ]
 
-# Banners to display to users. HTML is allowed.
+# Banners to display to users. Markdown and limited HTML are allowed.
 if "NAUTOBOT_BANNER_BOTTOM" in os.environ and os.environ["NAUTOBOT_BANNER_BOTTOM"] != "":
     BANNER_BOTTOM = os.environ["NAUTOBOT_BANNER_BOTTOM"]
 if "NAUTOBOT_BANNER_LOGIN" in os.environ and os.environ["NAUTOBOT_BANNER_LOGIN"] != "":
@@ -686,15 +686,15 @@
     ),
     "BANNER_BOTTOM": ConstanceConfigItem(
         default="",
-        help_text="Custom HTML to display in a banner at the bottom of all pages.",
+        help_text="Custom Markdown or limited HTML to display in a banner at the bottom of all pages.",
     ),
     "BANNER_LOGIN": ConstanceConfigItem(
         default="",
-        help_text="Custom HTML to display in a banner at the top of the login page.",
+        help_text="Custom Markdown or limited HTML to display in a banner at the top of the login page.",
     ),
     "BANNER_TOP": ConstanceConfigItem(
         default="",
-        help_text="Custom HTML to display in a banner at the top of all pages.",
+        help_text="Custom Markdown or limited HTML to display in a banner at the top of all pages.",
     ),
     "CHANGELOG_RETENTION": ConstanceConfigItem(
         default=90,
@@ -972,6 +972,8 @@
         "NAUTOBOT_BRANDING_FILEPATHS_HEADER_BULLET", None
     ),  # bullet image used for various view headers
     "nav_bullet": os.getenv("NAUTOBOT_BRANDING_FILEPATHS_NAV_BULLET", None),  # bullet image used for nav menu headers
+    "css": os.getenv("NAUTOBOT_BRANDING_FILEPATHS_CSS", None),  # Custom global CSS
+    "javascript": os.getenv("NAUTOBOT_BRANDING_FILEPATHS_JAVASCRIPT", None),  # Custom global JavaScript
 }
 
 # Title to use in place of "Nautobot"


Test patch:
diff --git a/nautobot/core/tests/test_views.py b/nautobot/core/tests/test_views.py
--- a/nautobot/core/tests/test_views.py
+++ b/nautobot/core/tests/test_views.py
@@ -146,6 +146,39 @@ def test_footer_version_visible_authenticated_users_only(self):
         response_content = response.content.decode(response.charset).replace("\n", "")
         self.assertNotRegex(response_content, footer_hostname_version_pattern)
 
+    def test_banners_markdown(self):
+        url = reverse("home")
+        with override_settings(
+            BANNER_TOP="# Hello world",
+            BANNER_BOTTOM="[info](https://nautobot.com)",
+        ):
+            response = self.client.get(url)
+        self.assertInHTML("<h1>Hello world</h1>", response.content.decode(response.charset))
+        self.assertInHTML(
+            '<a href="https://nautobot.com" rel="noopener noreferrer">info</a>',
+            response.content.decode(response.charset),
+        )
+
+        with override_settings(BANNER_LOGIN="_Welcome to Nautobot!_"):
+            self.client.logout()
+            response = self.client.get(reverse("login"))
+        self.assertInHTML("<em>Welcome to Nautobot!</em>", response.content.decode(response.charset))
+
+    def test_banners_no_xss(self):
+        url = reverse("home")
+        with override_settings(
+            BANNER_TOP='<script>alert("Hello from above!");</script>',
+            BANNER_BOTTOM='<script>alert("Hello from below!");</script>',
+        ):
+            response = self.client.get(url)
+        self.assertNotIn("Hello from above", response.content.decode(response.charset))
+        self.assertNotIn("Hello from below", response.content.decode(response.charset))
+
+        with override_settings(BANNER_LOGIN='<script>alert("Welcome to Nautobot!");</script>'):
+            self.client.logout()
+            response = self.client.get(reverse("login"))
+        self.assertNotIn("Welcome to Nautobot!", response.content.decode(response.charset))
+
 
 @override_settings(BRANDING_TITLE="Nautobot")
 class SearchFieldsTestCase(TestCase):