From 48135debc17fa0d38a7e3f92c8066f725e0193c3 Mon Sep 17 00:00:00 2001 From: Jordan Robinson Date: Thu, 25 Sep 2025 22:20:21 +0100 Subject: [PATCH] add custom CSS functionality --- CHANGELOG.md | 4 ++ main/admin.py | 1 + main/migrations/0123_user_custom_css.py | 18 +++++ main/migrations/0124_alter_user_custom_css.py | 18 +++++ main/models.py | 68 +++++++++++++++++++ main/templates/main/layout.html | 6 ++ main/views/general.py | 1 + 7 files changed, 116 insertions(+) create mode 100644 main/migrations/0123_user_custom_css.py create mode 100644 main/migrations/0124_alter_user_custom_css.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 65ab7bb65fe1ed50e00797f94b244564ebe66c4d..eeaa26839c38f2a9808582e6a6e5e47fd9a9b891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project will be documented in this file. +## [1.3.5](https://git.sr.ht/~linuxgoose/bocpress/tree/v1.3.5) +* add custom CSS functionality +* add number of posts in feed setting + ## [1.3.4](https://git.sr.ht/~linuxgoose/bocpress/tree/v1.3.4) * add homepage content settings page * add tags support for posts diff --git a/main/admin.py b/main/admin.py index 396598c33881e485d0590c7dafb0155267be8d15..3a37a6d73d42d62aae6fc92f6e135151c3913c09 100644 --- a/main/admin.py +++ b/main/admin.py @@ -76,6 +76,7 @@ class UserAdmin(DjUserAdmin): "api_key", "robots_txt", "number_of_posts_feed", + "custom_css", ), }, ), diff --git a/main/migrations/0123_user_custom_css.py b/main/migrations/0123_user_custom_css.py new file mode 100644 index 0000000000000000000000000000000000000000..9cd293646ca329bdde8f8f6938730042343af50a --- /dev/null +++ b/main/migrations/0123_user_custom_css.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2025-09-25 20:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0122_user_show_tags_in_post_list_alter_post_tags'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='custom_css', + field=models.TextField(blank=True, default='', help_text='Custom CSS for your blog to override certain parts, or all of, the default theme. Leave blank for no overrides.', null=True, verbose_name='Custom CSS'), + ), + ] diff --git a/main/migrations/0124_alter_user_custom_css.py b/main/migrations/0124_alter_user_custom_css.py new file mode 100644 index 0000000000000000000000000000000000000000..b3ced3c2d68a9f4493c55cb18914b34ec8d52384 --- /dev/null +++ b/main/migrations/0124_alter_user_custom_css.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2025-09-25 21:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0123_user_custom_css'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='custom_css', + field=models.TextField(blank=True, default='', help_text="Custom CSS for your blog to override certain parts, or all of, the default theme. Leave blank for no overrides. Click here to learn about the allowed CSS properties.", null=True, verbose_name='Custom CSS'), + ), + ] diff --git a/main/models.py b/main/models.py index fda52dc330feba435f2668b023a0538d6105fa7a..3957b07343eff6997a452cefac6772493bdd3767 100644 --- a/main/models.py +++ b/main/models.py @@ -14,6 +14,62 @@ from django.utils import timezone from main import util, validators +ALLOWED_CSS_PROPERTIES = { + # Layout / Box + "margin", "margin-top", "margin-right", "margin-bottom", "margin-left", + "padding", "padding-top", "padding-right", "padding-bottom", "padding-left", + "width", "min-width", "max-width", "height", "min-height", "max-height", + "box-sizing", "display", "position", "top", "right", "bottom", "left", "z-index", + "overflow", "overflow-x", "overflow-y", + + # Typography + "font-family", "font-size", "font-weight", "font-style", "line-height", + "letter-spacing", "word-spacing", "text-align", "text-decoration", + "text-transform", "white-space", + + # Color / Background + "color", "background", "background-color", "background-size", + "background-repeat", "background-position", "opacity", + + # Borders / Outline + "border", "border-top", "border-right", "border-bottom", "border-left", + "border-color", "border-width", "border-style", "border-radius", + "outline", "outline-color", "outline-style", "outline-width", + + # Flex / Grid + "flex", "flex-direction", "flex-wrap", "flex-basis", "flex-grow", "flex-shrink", + "justify-content", "align-items", "align-self", "align-content", + "gap", "row-gap", "column-gap", + "grid-template-columns", "grid-template-rows", "grid-gap", + "grid-column", "grid-row", + + # Visual effects + "box-shadow", "cursor", "transition", + "transform", "overflow-wrap" +} + +def sanitize_css(css_string: str) -> str: + """ + Keep only allowed CSS properties, remove unsafe content. + """ + safe_rules = [] + for rule in css_string.split('}'): + if '{' not in rule: + continue + selector, props = rule.split('{', 1) + safe_props = [] + for prop in props.split(';'): + if ':' not in prop: + continue + name, value = prop.split(':', 1) + name = name.strip().lower() + value = value.strip() + if name in ALLOWED_CSS_PROPERTIES and 'expression' not in value.lower() and 'javascript:' not in value.lower(): + safe_props.append(f"{name}: {value}") + if safe_props: + safe_rules.append(f"{selector.strip()} {{ {'; '.join(safe_props)} }}") + return ' '.join(safe_rules) + def _generate_key(): """Return 32-char random string.""" @@ -174,6 +230,13 @@ class User(AbstractUser): null=False, default=10, ) + custom_css = models.TextField( + blank=True, + help_text="Custom CSS for your blog to override certain parts, or all of, the default theme. Leave blank for no overrides. Click here to learn about the allowed CSS properties.", + verbose_name="Custom CSS", + null=True, + default="", + ) # webring related webring_name = models.CharField(max_length=200, blank=True, null=True) @@ -278,6 +341,11 @@ class User(AbstractUser): self.api_key = _generate_key() self.save() + def save(self, *args, **kwargs): + if self.custom_css: + self.custom_css = sanitize_css(self.custom_css) + super().save(*args, **kwargs) + def __str__(self): return self.username diff --git a/main/templates/main/layout.html b/main/templates/main/layout.html index f63847f01eea2fe89f7dc3009f8eac9c90760bc1..55d014239f0da3b4f0f9666d709a883ed85defbd 100644 --- a/main/templates/main/layout.html +++ b/main/templates/main/layout.html @@ -28,6 +28,12 @@ {% include 'assets/style.css' %} + {% block extra_css %} + + {% endblock %} + {% block head_rsl_license %} {% endblock head_rsl_license %} diff --git a/main/views/general.py b/main/views/general.py index ccffe2091488b96cf8a08a2e334a5281be8c7aad..727b19922c2b41fffc93f704519d4cd86ae9f255 100644 --- a/main/views/general.py +++ b/main/views/general.py @@ -388,6 +388,7 @@ class UserUpdate(LoginRequiredMixin, SuccessMessageMixin, UpdateView): "reading_time_on", "robots_txt", "number_of_posts_feed", + "custom_css", ] template_name = "main/user_update.html" success_message = "settings updated"