diff --git a/bookmarks/migrations/0042_userprofile_custom_css_hash.py b/bookmarks/migrations/0042_userprofile_custom_css_hash.py new file mode 100644 index 00000000..bc027ac8 --- /dev/null +++ b/bookmarks/migrations/0042_userprofile_custom_css_hash.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.1 on 2024-09-28 08:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookmarks", "0041_merge_metadata"), + ] + + operations = [ + migrations.AddField( + model_name="userprofile", + name="custom_css_hash", + field=models.CharField(blank=True, max_length=32), + ), + ] diff --git a/bookmarks/models.py b/bookmarks/models.py index 2b94cfac..fe4530bb 100644 --- a/bookmarks/models.py +++ b/bookmarks/models.py @@ -1,4 +1,5 @@ import binascii +import hashlib import logging import os from typing import List @@ -430,6 +431,7 @@ class UserProfile(models.Model): display_remove_bookmark_action = models.BooleanField(default=True, null=False) permanent_notes = models.BooleanField(default=False, null=False) custom_css = models.TextField(blank=True, null=False) + custom_css_hash = models.CharField(blank=True, null=False, max_length=32) auto_tagging_rules = models.TextField(blank=True, null=False) search_preferences = models.JSONField(default=dict, null=False) enable_automatic_html_snapshots = models.BooleanField(default=True, null=False) @@ -439,6 +441,15 @@ class UserProfile(models.Model): ) sticky_pagination = models.BooleanField(default=False, null=False) + def save(self, *args, **kwargs): + if self.custom_css: + self.custom_css_hash = hashlib.md5( + self.custom_css.encode("utf-8") + ).hexdigest() + else: + self.custom_css_hash = "" + super().save(*args, **kwargs) + class UserProfileForm(forms.ModelForm): class Meta: diff --git a/bookmarks/templates/bookmarks/head.html b/bookmarks/templates/bookmarks/head.html index 61dd8fb6..b28a1caf 100644 --- a/bookmarks/templates/bookmarks/head.html +++ b/bookmarks/templates/bookmarks/head.html @@ -30,7 +30,7 @@ {% endif %} {% if request.user_profile.custom_css %} - + {% endif %} {% if not request.global_settings.enable_link_prefetch %} diff --git a/bookmarks/tests/test_custom_css.py b/bookmarks/tests/test_custom_css.py deleted file mode 100644 index ce0e91ea..00000000 --- a/bookmarks/tests/test_custom_css.py +++ /dev/null @@ -1,21 +0,0 @@ -from django.test import TestCase -from django.urls import reverse - -from bookmarks.tests.helpers import BookmarkFactoryMixin - - -class CustomCssTestCase(TestCase, BookmarkFactoryMixin): - def setUp(self): - self.client.force_login(self.get_or_create_test_user()) - - def test_does_not_render_custom_style_tag_by_default(self): - response = self.client.get(reverse("bookmarks:index")) - self.assertNotContains(response, "") diff --git a/bookmarks/tests/test_custom_css_view.py b/bookmarks/tests/test_custom_css_view.py new file mode 100644 index 00000000..e5d4c070 --- /dev/null +++ b/bookmarks/tests/test_custom_css_view.py @@ -0,0 +1,28 @@ +from django.test import TestCase +from django.urls import reverse + +from bookmarks.tests.helpers import BookmarkFactoryMixin + + +class CustomCssViewTestCase(TestCase, BookmarkFactoryMixin): + def setUp(self) -> None: + user = self.get_or_create_test_user() + self.client.force_login(user) + + def test_with_empty_css(self): + response = self.client.get(reverse("bookmarks:custom_css")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "text/css") + self.assertEqual(response.headers["Cache-Control"], "public, max-age=2592000") + self.assertEqual(response.content.decode(), "") + + def test_with_custom_css(self): + css = "body { background-color: red; }" + self.user.profile.custom_css = css + self.user.profile.save() + + response = self.client.get(reverse("bookmarks:custom_css")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "text/css") + self.assertEqual(response.headers["Cache-Control"], "public, max-age=2592000") + self.assertEqual(response.content.decode(), css) diff --git a/bookmarks/tests/test_layout.py b/bookmarks/tests/test_layout.py index e7fb2636..70122c02 100644 --- a/bookmarks/tests/test_layout.py +++ b/bookmarks/tests/test_layout.py @@ -2,10 +2,10 @@ from django.urls import reverse from bookmarks.models import GlobalSettings -from bookmarks.tests.helpers import BookmarkFactoryMixin +from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin -class LayoutTestCase(TestCase, BookmarkFactoryMixin): +class LayoutTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): def setUp(self) -> None: user = self.get_or_create_test_user() @@ -63,3 +63,38 @@ def test_metadata_should_respect_prefetch_links_setting(self): html, count=0, ) + + def test_does_not_link_custom_css_when_empty(self): + response = self.client.get(reverse("bookmarks:index")) + html = response.content.decode() + soup = self.make_soup(html) + + link = soup.select_one("link[rel='stylesheet'][href*='custom_css']") + self.assertIsNone(link) + + def test_does_link_custom_css_when_not_empty(self): + profile = self.get_or_create_test_user().profile + profile.custom_css = "body { background-color: red; }" + profile.save() + + response = self.client.get(reverse("bookmarks:index")) + html = response.content.decode() + soup = self.make_soup(html) + + link = soup.select_one("link[rel='stylesheet'][href*='custom_css']") + self.assertIsNotNone(link) + + def test_custom_css_link_href(self): + profile = self.get_or_create_test_user().profile + profile.custom_css = "body { background-color: red; }" + profile.save() + + response = self.client.get(reverse("bookmarks:index")) + html = response.content.decode() + soup = self.make_soup(html) + + link = soup.select_one("link[rel='stylesheet'][href*='custom_css']") + expected_url = ( + reverse("bookmarks:custom_css") + f"?hash={profile.custom_css_hash}" + ) + self.assertEqual(link["href"], expected_url) diff --git a/bookmarks/tests/test_settings_general_view.py b/bookmarks/tests/test_settings_general_view.py index 534b952c..10f5cf72 100644 --- a/bookmarks/tests/test_settings_general_view.py +++ b/bookmarks/tests/test_settings_general_view.py @@ -1,3 +1,4 @@ +import hashlib import random from unittest.mock import patch, Mock @@ -217,6 +218,31 @@ def test_update_profile_should_not_be_called_without_respective_form_action(self self.assertEqual(self.user.profile.theme, UserProfile.THEME_AUTO) self.assertSuccessMessage(html, "Profile updated", count=0) + def test_update_profile_updates_custom_css_hash(self): + form_data = self.create_profile_form_data( + { + "custom_css": "body { background-color: #000; }", + } + ) + self.client.post(reverse("bookmarks:settings.update"), form_data, follow=True) + self.user.profile.refresh_from_db() + + expected_hash = hashlib.md5(form_data["custom_css"].encode("utf-8")).hexdigest() + self.assertEqual(expected_hash, self.user.profile.custom_css_hash) + + form_data["custom_css"] = "body { background-color: #fff; }" + self.client.post(reverse("bookmarks:settings.update"), form_data, follow=True) + self.user.profile.refresh_from_db() + + expected_hash = hashlib.md5(form_data["custom_css"].encode("utf-8")).hexdigest() + self.assertEqual(expected_hash, self.user.profile.custom_css_hash) + + form_data["custom_css"] = "" + self.client.post(reverse("bookmarks:settings.update"), form_data, follow=True) + self.user.profile.refresh_from_db() + + self.assertEqual("", self.user.profile.custom_css_hash) + def test_enable_favicons_should_schedule_icon_update(self): with patch.object( tasks, "schedule_bookmarks_without_favicons" diff --git a/bookmarks/urls.py b/bookmarks/urls.py index 97b8ab1f..0372e192 100644 --- a/bookmarks/urls.py +++ b/bookmarks/urls.py @@ -65,4 +65,6 @@ path("health", views.health, name="health"), # Manifest path("manifest.json", views.manifest, name="manifest"), + # Custom CSS + path("custom_css", views.custom_css, name="custom_css"), ] diff --git a/bookmarks/views/__init__.py b/bookmarks/views/__init__.py index b9a9f0e4..53d66ad1 100644 --- a/bookmarks/views/__init__.py +++ b/bookmarks/views/__init__.py @@ -4,4 +4,5 @@ from .toasts import * from .health import health from .manifest import manifest +from .custom_css import custom_css from .root import root diff --git a/bookmarks/views/custom_css.py b/bookmarks/views/custom_css.py new file mode 100644 index 00000000..d2b7ebc5 --- /dev/null +++ b/bookmarks/views/custom_css.py @@ -0,0 +1,10 @@ +from django.http import HttpResponse + +custom_css_cache_max_age = 2592000 # 30 days + + +def custom_css(request): + css = request.user_profile.custom_css + response = HttpResponse(css, content_type="text/css") + response["Cache-Control"] = f"public, max-age={custom_css_cache_max_age}" + return response