Foro: editor CKEditor 5 en los mensajes y moderación por POST+CSRF

- forum/forms.py: TopicForm/ReplyForm/EditPostForm con CKEditor5Widget (rich text)
- vistas crear/responder/editar pasan el form; el HTML del editor se sanea con nh3
- acciones de moderación (bloquear, fijar, mover, borrar/restaurar temas y posts)
  ahora exigen POST y van con CSRF; las plantillas usan formularios en vez de enlaces GET
- docs/FORO.md actualizado

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 17:15:35 +00:00
parent 2c30844bf7
commit 874a0c1ad9
7 changed files with 73 additions and 23 deletions
+10 -7
View File
@@ -46,10 +46,16 @@ en `forum/permissions.py`:
- El texto de los posts se **sanea con `nh3`** (allowlist de etiquetas) al
guardar — `forum/sanitize.py` — evitando el XSS que tenía el original (que
renderizaba HTML del editor sin filtrar). Se renderiza con `|safe` ya saneado.
- Formularios de publicar/responder/editar/mover van por **POST con CSRF**.
- ⚠️ Pendiente de endurecer: las acciones de moderación (bloquear, borrar,
restaurar, fijar) son enlaces **GET** (como en el original). Para producción se
recomienda pasarlas a POST con CSRF.
- **Todas** las acciones que modifican datos (publicar, responder, editar, mover,
bloquear/desbloquear, fijar, borrar/restaurar temas y posts) van por **POST con
CSRF**. Las vistas de moderación rechazan GET.
## Editor
Los mensajes se escriben con **CKEditor 5** (el mismo editor que usa el resto de
NovaWoW), vía `forum/forms.py` (`TopicForm`, `ReplyForm`, `EditPostForm`). El
HTML que produce el editor se **sanea con nh3** al guardar, así que el rich text
es seguro.
## Despliegue
@@ -70,8 +76,5 @@ en `forum/permissions.py`:
## Notas / posibles mejoras
- Editor enriquecido (CKEditor/TinyMCE) en el textarea de posts (ahora es
texto/HTML saneado; los saltos de línea se respetan por CSS `pre-wrap`).
- El autor se muestra como el nombre de cuenta de juego (`<bnetId>#1`); se podría
mapear a un nick o al email como en «Mi cuenta».
- Endurecer las acciones de moderación a POST+CSRF.
+23
View File
@@ -0,0 +1,23 @@
"""
Formularios del foro con el editor enriquecido CKEditor 5 (el mismo que usa el
resto de NovaWoW). El HTML resultante se sanea con nh3 al guardar (ver views).
"""
from django import forms
from django_ckeditor_5.widgets import CKEditor5Widget
class TopicForm(forms.Form):
name = forms.CharField(
max_length=45,
widget=forms.TextInput(attrs={'placeholder': 'Título del tema', 'maxlength': 45}),
)
text = forms.CharField(widget=CKEditor5Widget(config_name='default'))
class ReplyForm(forms.Form):
text = forms.CharField(widget=CKEditor5Widget(config_name='default'))
class EditPostForm(forms.Form):
text = forms.CharField(widget=CKEditor5Widget(config_name='default'))
+3
View File
@@ -32,6 +32,9 @@
.forum-msg.error { background: rgba(196,30,58,.2); color: #ff9a9a; }
.forum-msg.success { background: rgba(46,160,67,.2); color: #9affb0; }
.forum-deleted { opacity: .55; }
.forum-inline { display: inline; }
.link-btn { background: none; border: none; color: #d79602; cursor: pointer; padding: 0; font: inherit; text-decoration: underline; }
.link-btn.danger { color: #ff9a9a; }
</style>
{% if messages %}
<div class="forum-messages forum-wrap">
+3 -2
View File
@@ -15,13 +15,14 @@
<p style="color:#b1997f">Foro: <strong>{{ forum.name }}</strong></p>
<form action="{% url 'forum_create_topic_submit' forum_id=forum.id %}" method="POST">
{% csrf_token %}
<input type="text" name="name" maxlength="45" placeholder="Título del tema" required>
<textarea name="text" placeholder="Contenido del tema..." required></textarea>
{{ form.name }}
{{ form.text }}
<div class="forum-actions">
<button type="submit" class="forum-btn">Publicar tema</button>
<a class="forum-btn" href="{% url 'forum_view_p1' forum_id=forum.id %}">Cancelar</a>
</div>
</form>
{{ form.media }}
</div>
</div>
</div>
+2 -1
View File
@@ -14,12 +14,13 @@
<div class="forum-wrap forum-form">
<form action="{% url 'forum_update_post' post_id=post.id %}" method="POST">
{% csrf_token %}
<textarea name="text" required>{{ post.text|safe }}</textarea>
{{ form.text }}
<div class="forum-actions">
<button type="submit" class="forum-btn">Guardar cambios</button>
<a class="forum-btn" href="{% url 'forum_topic_p1' topic_id=post.topic_id %}">Cancelar</a>
</div>
</form>
{{ form.media }}
</div>
</div>
</div>
+9 -8
View File
@@ -40,10 +40,10 @@
<span>
{% if post.can_edit and not post.deleted %}
<a href="{% url 'forum_edit_post' post_id=post.id %}">Editar</a>
· <a href="{% url 'forum_delete_post' post_id=post.id %}" onclick="return confirm('¿Borrar este mensaje?')">Borrar</a>
· <form class="forum-inline" action="{% url 'forum_delete_post' post_id=post.id %}" method="POST" onsubmit="return confirm('¿Borrar este mensaje?')">{% csrf_token %}<button type="submit" class="link-btn danger">Borrar</button></form>
{% endif %}
{% if post.deleted and forum_is_moderator %}
<a href="{% url 'forum_restore_post' post_id=post.id %}">Restaurar</a>
<form class="forum-inline" action="{% url 'forum_restore_post' post_id=post.id %}" method="POST">{% csrf_token %}<button type="submit" class="link-btn">Restaurar</button></form>
{% endif %}
</span>
</div>
@@ -69,11 +69,12 @@
<h3 style="color:#d79602">Responder</h3>
<form action="{% url 'forum_post_reply' topic_id=topic.id %}" method="POST">
{% csrf_token %}
<textarea name="text" placeholder="Escribe tu respuesta..." required></textarea>
{{ reply_form.text }}
<div class="forum-actions">
<button type="submit" class="forum-btn">Publicar respuesta</button>
</div>
</form>
{{ reply_form.media }}
</div>
{% elif topic.locked %}
<p style="color:#b1997f">Este tema está bloqueado. No se admiten nuevas respuestas.</p>
@@ -86,15 +87,15 @@
<h3 style="color:#d79602">Moderación</h3>
<div class="forum-actions">
{% if topic.locked %}
<a class="forum-btn" href="{% url 'forum_unlock_topic' topic_id=topic.id %}">Desbloquear</a>
<form class="forum-inline" action="{% url 'forum_unlock_topic' topic_id=topic.id %}" method="POST">{% csrf_token %}<button type="submit" class="forum-btn">Desbloquear</button></form>
{% else %}
<a class="forum-btn" href="{% url 'forum_lock_topic' topic_id=topic.id %}">Bloquear</a>
<form class="forum-inline" action="{% url 'forum_lock_topic' topic_id=topic.id %}" method="POST">{% csrf_token %}<button type="submit" class="forum-btn">Bloquear</button></form>
{% endif %}
<a class="forum-btn" href="{% url 'forum_toggle_sticky' topic_id=topic.id %}">{% if topic.sticky %}Quitar fijado{% else %}Fijar{% endif %}</a>
<form class="forum-inline" action="{% url 'forum_toggle_sticky' topic_id=topic.id %}" method="POST">{% csrf_token %}<button type="submit" class="forum-btn">{% if topic.sticky %}Quitar fijado{% else %}Fijar{% endif %}</button></form>
{% if topic.deleted %}
<a class="forum-btn" href="{% url 'forum_restore_topic' topic_id=topic.id %}">Restaurar tema</a>
<form class="forum-inline" action="{% url 'forum_restore_topic' topic_id=topic.id %}" method="POST">{% csrf_token %}<button type="submit" class="forum-btn">Restaurar tema</button></form>
{% else %}
<a class="forum-btn danger" href="{% url 'forum_delete_topic' topic_id=topic.id %}" onclick="return confirm('¿Borrar el tema completo?')">Borrar tema</a>
<form class="forum-inline" action="{% url 'forum_delete_topic' topic_id=topic.id %}" method="POST" onsubmit="return confirm('¿Borrar el tema completo?')">{% csrf_token %}<button type="submit" class="forum-btn danger">Borrar tema</button></form>
{% endif %}
</div>
{% if move_forums %}
+23 -5
View File
@@ -16,6 +16,7 @@ from django.urls import reverse
from . import db
from . import permissions as perm
from . import sanitize
from .forms import TopicForm, ReplyForm, EditPostForm
MIN_TEXT_LEN = 5
@@ -110,6 +111,7 @@ def view_topic(request, topic_id, page=1):
'pagination': pag,
'can_reply': perm.can_post(request) and not topic['locked'],
'move_forums': db.list_forums_for_move(topic['forum_id']) if perm.is_moderator(request) else [],
'reply_form': ReplyForm(),
}))
@@ -127,6 +129,7 @@ def create_topic(request, forum_id):
return redirect('app_forum')
return render(request, 'forum/create_topic.html', _base_context(request, {
'forum': forum,
'form': TopicForm(),
}))
@@ -204,6 +207,7 @@ def edit_post(request, post_id):
return redirect('forum_topic_p1', topic_id=post['topic_id'])
return render(request, 'forum/edit_post.html', _base_context(request, {
'post': post,
'form': EditPostForm(initial={'text': post['text']}),
}))
@@ -242,6 +246,8 @@ def delete_post(request, post_id):
if not perm.can_edit_post(request, post):
messages.error(request, 'No tienes permiso.')
return redirect('forum_topic_p1', topic_id=post['topic_id'])
if request.method != 'POST':
return redirect('forum_topic_p1', topic_id=post['topic_id'])
db.set_post_deleted(post_id, True)
messages.success(request, 'Post borrado.')
return redirect('forum_topic_p1', topic_id=post['topic_id'])
@@ -253,6 +259,8 @@ def restore_post(request, post_id):
post = db.get_post(post_id)
if not post:
return redirect('app_forum')
if request.method != 'POST':
return redirect('forum_topic_p1', topic_id=post['topic_id'])
db.set_post_deleted(post_id, False)
messages.success(request, 'Post restaurado.')
return redirect('forum_topic_p1', topic_id=post['topic_id'])
@@ -269,8 +277,18 @@ def _mod_only(request):
return None
def lock_topic(request, topic_id):
def _mod_post_only(request):
"""Exige moderador y método POST (CSRF)."""
block = _mod_only(request)
if block:
return block
if request.method != 'POST':
return redirect('app_forum')
return None
def lock_topic(request, topic_id):
block = _mod_post_only(request)
if block:
return block
db.set_topic_flag(topic_id, 'locked', True)
@@ -279,7 +297,7 @@ def lock_topic(request, topic_id):
def unlock_topic(request, topic_id):
block = _mod_only(request)
block = _mod_post_only(request)
if block:
return block
db.set_topic_flag(topic_id, 'locked', False)
@@ -288,7 +306,7 @@ def unlock_topic(request, topic_id):
def toggle_sticky(request, topic_id):
block = _mod_only(request)
block = _mod_post_only(request)
if block:
return block
topic = db.get_topic(topic_id, include_hidden=True)
@@ -299,7 +317,7 @@ def toggle_sticky(request, topic_id):
def delete_topic(request, topic_id):
block = _mod_only(request)
block = _mod_post_only(request)
if block:
return block
topic = db.get_topic(topic_id, include_hidden=True)
@@ -311,7 +329,7 @@ def delete_topic(request, topic_id):
def restore_topic(request, topic_id):
block = _mod_only(request)
block = _mod_post_only(request)
if block:
return block
db.set_topic_flag(topic_id, 'deleted', False)