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:
+10
-7
@@ -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.
|
||||
|
||||
@@ -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'))
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user