Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ classifiers = [
]

dependencies = [
"bleach[css]~=6.3",
"celery[redis]~=5.6.0",
"crispy-bootstrap5~=2025.6",
"django-activity-stream~=2.0.0",
Expand Down Expand Up @@ -57,6 +56,9 @@ dependencies = [
"icalendar~=6.3.2",
"invoke~=2.2.1",
"lingua-language-detector~=2.1.1",
"markdownify~=1.2",
"markdown-it-py~=4.0",
"nh3~=0.3",
"openfoodfacts~=3.3.0",
"packaging~=26.0",
"pillow~=12.0.0",
Expand Down
133 changes: 94 additions & 39 deletions uv.lock

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions wger/exercises/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ class Meta:
model = Translation
fields = (
'name',
'description',
'description_source',
'language',
'aliases',
'comments',
Expand All @@ -414,7 +414,7 @@ def validate(self, data):
)

language = data.get('language')
description = data.get('description')
description = data.get('description_source')

# Try to detect the language
detected_language = detector.detect_language_of(description)
Expand Down Expand Up @@ -468,6 +468,7 @@ class ExerciseTranslationSerializer(serializers.ModelSerializer):

id = serializers.IntegerField(required=False, read_only=True)
uuid = serializers.UUIDField(required=False, read_only=True)
description_source = serializers.CharField(required=False, allow_blank=True)
exercise = serializers.PrimaryKeyRelatedField(
queryset=Exercise.objects.all(),
required=True,
Expand All @@ -481,11 +482,14 @@ class Meta:
'name',
'exercise',
'description',
'description_source',
'created',
'language',
'license_author',
)

read_only_fields = 'description' # Prevents API from accepting raw HTML

def validate(self, value):
"""
Check that there is only one language per exercise
Expand Down
23 changes: 2 additions & 21 deletions wger/exercises/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,7 @@
from django.views.decorators.cache import cache_page

# Third Party
import bleach
from actstream import action as actstream_action
from bleach.css_sanitizer import CSSSanitizer
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
OpenApiParameter,
Expand Down Expand Up @@ -173,15 +171,7 @@ def perform_create(self, serializer):
"""
Save entry to activity stream
"""
# Clean the description HTML
if serializer.validated_data.get('description'):
serializer.validated_data['description'] = bleach.clean(
serializer.validated_data['description'],
tags=HTML_TAG_WHITELIST,
attributes=HTML_ATTRIBUTES_WHITELIST,
css_sanitizer=CSSSanitizer(allowed_css_properties=HTML_STYLES_WHITELIST),
strip=True,
)

super().perform_create(serializer)

actstream_action.send(
Expand All @@ -202,17 +192,8 @@ def perform_update(self, serializer):
if serializer.validated_data.get('language'):
del serializer.validated_data['language']

# Clean the description HTML
if serializer.validated_data.get('description'):
serializer.validated_data['description'] = bleach.clean(
serializer.validated_data['description'],
tags=HTML_TAG_WHITELIST,
attributes=HTML_ATTRIBUTES_WHITELIST,
css_sanitizer=CSSSanitizer(allowed_css_properties=HTML_STYLES_WHITELIST),
strip=True,
)

super().perform_update(serializer)

actstream_action.send(
self.request.user,
verb=StreamVerbs.UPDATED.value,
Expand Down
39 changes: 39 additions & 0 deletions wger/exercises/migrations/0035_add_markdown_description_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Generated by Django 5.2.9 on 2025-12-10 00:17

from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
from markdownify import markdownify

from django.db import migrations, models

def migrate_description_to_markdown(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor):
Translation = apps.get_model('exercises', 'Translation')

translations_to_migrate = Translation.objects.exclude(description='')
for trans in translations_to_migrate:
# ATX to ensure # headings instead of underlined headings.
trans.description_source = markdownify(trans.description, heading_style='ATX')

# save() triggers "description = render_markdown(description_source)". This
# ensures a clean HTML cache
trans.save()


class Migration(migrations.Migration):
dependencies = [
('exercises', '0034_add_exercise_image_dimensions'),
]

operations = [
migrations.AddField(
model_name='historicaltranslation',
name='description_source',
field=models.TextField(blank=True, null=True, verbose_name='Description (Source)'),
),
migrations.AddField(
model_name='translation',
name='description_source',
field=models.TextField(blank=True, null=True, verbose_name='Description (Source)'),
),
migrations.RunPython(migrate_description_to_markdown),
]
20 changes: 18 additions & 2 deletions wger/exercises/models/translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,16 @@
from django.utils.text import slugify

# Third Party
import bleach
from simple_history.models import HistoricalRecords

# wger
from wger.core.models import Language
from wger.exercises.models import Exercise
from wger.utils.cache import reset_exercise_api_cache
from wger.utils.markdown import (
render_markdown,
sanitize_html,
)
from wger.utils.models import (
AbstractHistoryMixin,
AbstractLicenseModel,
Expand All @@ -50,6 +53,13 @@ class Translation(AbstractLicenseModel, AbstractHistoryMixin, models.Model):
)
"""Description on how to perform the exercise"""

description_source = models.TextField(
verbose_name='Description (Source)',
blank=True,
null=True,
)
"""The raw Markdown source"""

name = models.CharField(
verbose_name='Name',
max_length=200,
Expand Down Expand Up @@ -127,6 +137,12 @@ def save(self, *args, **kwargs):
"""
Reset all cached infos
"""

if self.description_source:
self.description = render_markdown(self.description_source)
elif self.description:
self.description = sanitize_html(self.description)

super().save(*args, **kwargs)

# Api cache
Expand Down Expand Up @@ -202,7 +218,7 @@ def description_clean(self):
"""
Return the exercise description with all markup removed
"""
return bleach.clean(self.description, strip=True)
return sanitize_html(self.description)

def get_owner_object(self):
"""
Expand Down
2 changes: 1 addition & 1 deletion wger/exercises/tests/test_exercise_translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ def test_patch_clean_html(self):
self.assertEqual(response.status_code, status.HTTP_200_OK)

translation = Translation.objects.get(pk=self.pk)
self.assertEqual(translation.description, 'alert(); The wild boar is a suid native...')
self.assertEqual(translation.description, ' The wild boar is a suid native...')

def test_post_only_one_language_per_base(self):
"""
Expand Down
19 changes: 5 additions & 14 deletions wger/utils/generic_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@
from django.views.generic.edit import ModelFormMixin

# Third Party
import bleach
from bleach.css_sanitizer import CSSSanitizer
from crispy_forms.helper import FormHelper
from crispy_forms.layout import (
ButtonHolder,
Expand All @@ -46,6 +44,7 @@
HTML_STYLES_WHITELIST,
HTML_TAG_WHITELIST,
)
from wger.utils.markdown import sanitize_html


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -142,7 +141,7 @@ class WgerFormMixin(ModelFormMixin):

clean_html = ()
"""
List of form fields that should be passed to bleach to clean the html
List of form fields that should be passed to wger.utils.markdown to clean the html
"""

messages = ''
Expand Down Expand Up @@ -229,17 +228,9 @@ def form_valid(self, form):
"""

for field in self.clean_html:
setattr(
form.instance,
field,
bleach.clean(
getattr(form.instance, field),
tags=HTML_TAG_WHITELIST,
attributes=HTML_ATTRIBUTES_WHITELIST,
css_sanitizer=CSSSanitizer(allowed_css_properties=HTML_STYLES_WHITELIST),
strip=True,
),
)
raw_value = getattr(form.instance, field)
clean_value = sanitize_html(raw_value)
setattr(form.instance, field, clean_value)

if self.get_messages():
messages.success(self.request, self.get_messages())
Expand Down
50 changes: 50 additions & 0 deletions wger/utils/markdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-

# This file is part of wger Workout Manager.
#
# wger Workout Manager is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# wger Workout Manager is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License

# Third Party
# Third party
import nh3
from markdown_it import MarkdownIt


def render_markdown(text):
"""
Renders markdown text to HTML and sanitizes it to allow only basic markup.
"""
if not text:
return ''

# Render Markdown to HTML
md = MarkdownIt('commonmark', {'breaks': True, 'html': True})
raw_html = md.render(text)

# Sanitize HTML
ALLOWED_TAGS = {'b', 'strong', 'i', 'em', 'ul', 'ol', 'li', 'p'}

clean_html = nh3.clean(raw_html, tags=ALLOWED_TAGS, attributes={})

return clean_html


def sanitize_html(text):
"""
Directly sanitizes HTML (for legacy fields or non-markdown inputs)
"""
if not text:
return ''

ALLOWED_TAGS = {'b', 'strong', 'i', 'em', 'ul', 'ol', 'li', 'p'}
return nh3.clean(text, tags=ALLOWED_TAGS, attributes={})
Loading