~linuxgoose/bocpress

48135debc17fa0d38a7e3f92c8066f725e0193c3 — Jordan Robinson 2 months ago c914679 v1.3.5
add custom CSS functionality
M CHANGELOG.md => CHANGELOG.md +4 -0
@@ 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

M main/admin.py => main/admin.py +1 -0
@@ 76,6 76,7 @@ class UserAdmin(DjUserAdmin):
                    "api_key",
                    "robots_txt",
                    "number_of_posts_feed",
                    "custom_css",
                ),
            },
        ),

A main/migrations/0123_user_custom_css.py => main/migrations/0123_user_custom_css.py +18 -0
@@ 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'),
        ),
    ]

A main/migrations/0124_alter_user_custom_css.py => main/migrations/0124_alter_user_custom_css.py +18 -0
@@ 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 <a href='https://docs.bocpress.co.uk/custom-css/' target='_blank' rel='noopener noreferrer'>here</a> to learn about the allowed CSS properties.", null=True, verbose_name='Custom CSS'),
        ),
    ]

M main/models.py => main/models.py +68 -0
@@ 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 <a href='https://docs.bocpress.co.uk/custom-css/' target='_blank' rel='noopener noreferrer'>here</a> 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


M main/templates/main/layout.html => main/templates/main/layout.html +6 -0
@@ 28,6 28,12 @@
            {% include 'assets/style.css' %}
        </style>

        {% block extra_css %}
        <style>
        {{ request.blog_user.custom_css|safe }}
        </style>
        {% endblock %}

        {% block head_rsl_license %}
        {% endblock head_rsl_license %}
    </head>

M main/views/general.py => main/views/general.py +1 -0
@@ 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"