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