Jelajahi Sumber

Fixes #23450: Load fonts and icons when static files are served from signed URLs

Jeremy Stretch 2 hari lalu
induk
melakukan
4dd3b9495c

+ 36 - 0
netbox/netbox/tests/test_views.py

@@ -1,8 +1,11 @@
+import re
 import urllib.parse
 from contextlib import contextmanager
 from unittest.mock import patch
 
+from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
+from django.contrib.staticfiles.storage import StaticFilesStorage
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.db.models.signals import pre_delete
 from django.http import HttpResponse
@@ -361,6 +364,39 @@ class MediaViewTestCase(TestCase):
         self.assertHttpStatus(response, 404)
 
 
+class SignedStaticFilesStorage(StaticFilesStorage):
+    """
+    Emulate a storage backend which signs each static URL (e.g. S3 presigned URLs).
+    """
+    def url(self, name):
+        return f'{super().url(name)}?X-Amz-Signature={name}'
+
+
+class FontStylesheetTestCase(TestCase):
+
+    def test_unsigned_static_urls(self):
+        response = Client().get(reverse('login'))
+        self.assertContains(response, f'href="/static/netbox-fonts.css?v={settings.RELEASE.version}"')
+
+    @override_settings(STORAGES={
+        **settings.STORAGES,
+        'staticfiles': {'BACKEND': f'{__name__}.SignedStaticFilesStorage'},
+    })
+    def test_signed_static_urls(self):
+        response = Client().get(reverse('login'))
+        self.assertContains(response, f'href="{reverse("font_stylesheet")}"')
+
+        # Each font URL must carry its own signature
+        response = Client().get(reverse('font_stylesheet'))
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(response['Content-Type'], 'text/css')
+        self.assertIn('no-cache', response['Cache-Control'])
+        urls = re.findall(r'url\(([^)]*)\)', response.content.decode())
+        self.assertTrue(urls)
+        for url in urls:
+            self.assertRegex(url, r'^"/static/([^/"?]+)\?X-Amz-Signature=\1"$')
+
+
 class DeleteAtomicityMixin:
     """
     Scenario shared by the transactional and autocommit variants of the ObjectDeleteView

+ 4 - 1
netbox/netbox/urls.py

@@ -9,7 +9,7 @@ from netbox.api.views import APIRootView, AuthenticationCheckView, StatusView
 from netbox.graphql.schema import schema
 from netbox.graphql.views import NetBoxGraphQLView
 from netbox.plugins.urls import plugin_api_patterns, plugin_patterns
-from netbox.views import HomeView, MediaView, SearchView, StaticMediaFailureView, htmx
+from netbox.views import FontStylesheetView, HomeView, MediaView, SearchView, StaticMediaFailureView, htmx
 
 _patterns = [
 
@@ -74,6 +74,9 @@ _patterns = [
     path('media/<path:path>', MediaView.as_view(), name='media'),
     path('media-failure/', StaticMediaFailureView.as_view(), name='media_failure'),
 
+    # Font stylesheet, for static files served from signed URLs
+    path('fonts.css', FontStylesheetView.as_view(), name='font_stylesheet'),
+
     # Plugins
     path('plugins/', include((plugin_patterns, 'plugins'))),
     path('api/plugins/', include((plugin_api_patterns, 'plugins-api'))),

+ 22 - 1
netbox/netbox/views/misc.py

@@ -6,11 +6,15 @@ from collections import namedtuple
 from django.conf import settings
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
+from django.contrib.staticfiles import finders
 from django.core.cache import cache
 from django.db.models import Q
-from django.http import Http404
+from django.http import Http404, HttpResponse
 from django.shortcuts import redirect, render
+from django.templatetags.static import static
+from django.utils.decorators import method_decorator
 from django.utils.translation import gettext_lazy as _
+from django.views.decorators.cache import never_cache
 from django.views.generic import View
 from django.views.static import serve
 from django_tables2 import RequestConfig
@@ -29,6 +33,7 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.views import ConditionalLoginRequiredMixin, TokenConditionalLoginRequiredMixin
 
 __all__ = (
+    'FontStylesheetView',
     'HomeView',
     'MediaView',
     'SearchView',
@@ -38,6 +43,9 @@ logger = logging.getLogger(f'netbox.{__name__}')
 
 Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count'))
 
+# A relative URL in a compiled stylesheet, e.g. url("./Inter-Regular-373XMOLM.woff2?v=3.18")
+RELATIVE_URL = re.compile(r'url\("\./([^"?#]+)[^"]*"\)')
+
 
 class HomeView(ConditionalLoginRequiredMixin, View):
     template_name = 'home.html'
@@ -162,3 +170,16 @@ class MediaView(TokenConditionalLoginRequiredMixin, View):
         response['Content-Security-Policy'] = "sandbox; default-src 'none'"
         response['X-Content-Type-Options'] = "nosniff"
         return response
+
+
+@method_decorator(never_cache, name='dispatch')
+class FontStylesheetView(View):
+    """
+    Serve netbox-fonts.css with its relative font URLs replaced by static URLs, which may be signed.
+    """
+    def get(self, request):
+        if not (path := finders.find('netbox-fonts.css')):
+            raise Http404
+        with open(path, encoding='utf-8') as f:
+            css = RELATIVE_URL.sub(lambda m: f'url("{static(m[1])}")', f.read())
+        return HttpResponse(css, content_type='text/css')

+ 1 - 0
netbox/project-static/bundle.cjs

@@ -109,6 +109,7 @@ async function bundleStyles() {
   try {
     const entryPoints = {
       'netbox-external': 'styles/external.scss',
+      'netbox-fonts': 'styles/fonts.scss',
       'netbox': 'styles/netbox.scss',
       rack_elevation: 'styles/svg/rack_elevation.scss',
       cable_trace: 'styles/svg/cable_trace.scss',

File diff ditekan karena terlalu besar
+ 0 - 0
netbox/project-static/dist/netbox-external.css


File diff ditekan karena terlalu besar
+ 0 - 0
netbox/project-static/dist/netbox-fonts.css


+ 7 - 5
netbox/project-static/styles/external.scss

@@ -1,8 +1,10 @@
 // Entry for all 3rd party library imports that do not rely on Tabler or NetBox styles.
-@import '../node_modules/@mdi/font/css/materialdesignicons.min.css';
+// Fonts are bundled separately, in fonts.scss.
+@import '@mdi/font/scss/variables';
+@import '@mdi/font/scss/functions';
+@import '@mdi/font/scss/core';
+@import '@mdi/font/scss/icons';
+@import '@mdi/font/scss/extras';
+@import '@mdi/font/scss/animated';
 @import '../node_modules/flatpickr/dist/flatpickr.min.css';
 @import 'gridstack/dist/gridstack.min.css';
-
-// Fonts
-@import url("../node_modules/typeface-inter/inter.css");
-@import url("../node_modules/typeface-roboto-mono/index.css");

+ 7 - 0
netbox/project-static/styles/fonts.scss

@@ -0,0 +1,7 @@
+// @font-face rules for the bundled fonts. NetBox rewrites their URLs when static files are served from signed URLs.
+$mdi-font-path: '../node_modules/@mdi/font/fonts';
+@import '@mdi/font/scss/variables';
+@import '@mdi/font/scss/path';
+
+@import url('../node_modules/typeface-inter/inter.css');
+@import url('../node_modules/typeface-roboto-mono/index.css');

+ 12 - 0
netbox/templates/base/base.html

@@ -42,6 +42,18 @@
       href="{% static_with_params 'netbox-external.css' v=settings.RELEASE.version %}"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox-external.css'"
     />
+    {# Fonts. If static URLs carry a query string (e.g. a signature), NetBox must rewrite the relative font URLs. #}
+    {% static 'netbox-fonts.css' as fonts_url %}
+    {% if '?' in fonts_url %}
+      {% url 'font_stylesheet' as fonts_url %}
+    {% else %}
+      {% static_with_params 'netbox-fonts.css' v=settings.RELEASE.version as fonts_url %}
+    {% endif %}
+    <link
+      rel="stylesheet"
+      href="{{ fonts_url }}"
+      onerror="window.location='{% url 'media_failure' %}?filename=netbox-fonts.css'"
+    />
     <link
       rel="stylesheet"
       href="{% static_with_params 'netbox.css' v=settings.RELEASE.version %}"

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini