Skip to content
Open
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
123 changes: 121 additions & 2 deletions app/controllers/comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@
from html import escape as html_escape
from flask import Blueprint, request, redirect, flash, session, render_template
import bleach
from app.models.comment import comment_create, comment_by_id, comment_set_spoiler, get_thread_participants
from app.models.comment import (
comment_create, comment_by_id, comment_set_spoiler,
comment_update, comment_delete, get_thread_participants
)
from app.models.solution import get_solution_authors
from app.models.crackme import (
crackme_by_hexid, crackme_increment_comments, crackme_is_auto_validated
crackme_by_hexid, crackme_increment_comments, crackme_decrement_comments,
crackme_is_auto_validated
)
from app.models.notification import notification_add
from app.models.errors import ErrNoResult
Expand Down Expand Up @@ -276,3 +280,118 @@ def mark_spoiler_by_token(comment_id, token):
return render_template('comment/spoiler_marked.html',
success=False,
error="Comment not found.")


@comment_bp.route('/comment/<comment_id>/edit', methods=['POST'])
@login_required
@limit("30 per hour", key_func=lambda: session.get('name'))
def edit_comment(comment_id):
"""Edit a comment.

Permissions:
- Comment author: can edit their own comment
"""
username = session.get('name')

try:
comment = comment_by_id(comment_id)
except ErrNoResult:
flash('Comment not found.', FLASH_ERROR)
return redirect('/')

crackme_hexid = comment.get('crackmehexid')
comment_author = comment.get('author')

# Only comment author can edit
if comment_author != username:
flash('You can only edit your own comments.', FLASH_ERROR)
return redirect(f'/crackme/{crackme_hexid}' if crackme_hexid else '/')

# Validate required fields
is_valid, missing = validate_required(request.form, ['comment'])
if not is_valid:
flash(f'Field missing: {missing}', FLASH_ERROR)
return redirect(f'/crackme/{crackme_hexid}' if crackme_hexid else '/')

comment_text = bleach.clean(request.form.get('comment', ''))
if not comment_text.strip():
flash('Comment cannot be empty.', FLASH_ERROR)
return redirect(f'/crackme/{crackme_hexid}' if crackme_hexid else '/')

old_content = comment.get('info', '')

try:
comment_update(comment_id, comment_text)
except Exception as e:
print(f"Error updating comment: {e}")
flash('Failed to update comment. Please try again later.', FLASH_ERROR)
return redirect(f'/crackme/{crackme_hexid}' if crackme_hexid else '/')

# Handle any new @mentions added during editing
try:
old_mentions = parse_mentions(old_content)
new_mentions = parse_mentions(comment_text) - old_mentions
if new_mentions and crackme_hexid:
crackme = crackme_by_hexid(crackme_hexid)
crackme_author = crackme.get('author')
crackme_name = crackme['name']

thread_participants = get_thread_participants(crackme_hexid)
solution_authors = get_solution_authors(crackme_hexid)
valid_targets = thread_participants | solution_authors | {crackme_author}

for mentioned_user in new_mentions:
if mentioned_user == username:
continue
if mentioned_user in valid_targets:
notification_add(
mentioned_user,
f"You were mentioned by <a href=\"/user/{html_escape(username)}\">{html_escape(username)}</a> in a comment on '<a href=\"/crackme/{crackme_hexid}\">{html_escape(crackme_name)}</a>'"
)
except Exception as e:
print(f"Mention notification error on edit: {e}")

flash('Comment updated!', FLASH_SUCCESS)
return redirect(f'/crackme/{crackme_hexid}' if crackme_hexid else '/')


@comment_bp.route('/comment/<comment_id>/delete', methods=['POST'])
@login_required
def delete_comment(comment_id):
"""Delete a comment.

Permissions:
- Comment author: can delete their own comment
"""
username = session.get('name')

try:
comment = comment_by_id(comment_id)
except ErrNoResult:
flash('Comment not found.', FLASH_ERROR)
return redirect('/')

crackme_hexid = comment.get('crackmehexid')
comment_author = comment.get('author')

# Only comment author can delete
if comment_author != username:
flash('You can only delete your own comments.', FLASH_ERROR)
return redirect(f'/crackme/{crackme_hexid}' if crackme_hexid else '/')

try:
comment_delete(comment_id)
except Exception as e:
print(f"Error deleting comment: {e}")
flash('Failed to delete comment. Please try again later.', FLASH_ERROR)
return redirect(f'/crackme/{crackme_hexid}' if crackme_hexid else '/')

# Decrement comment count on crackme
if crackme_hexid:
try:
crackme_decrement_comments(crackme_hexid)
except Exception as e:
print(f"Failed to decrement comment count: {e}")

flash('Comment deleted.', FLASH_SUCCESS)
return redirect(f'/crackme/{crackme_hexid}' if crackme_hexid else '/')
80 changes: 78 additions & 2 deletions app/models/comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ def comment_by_id(comment_id):
collection = get_collection('comment')

if isinstance(comment_id, str):
comment_id = ObjectId(comment_id)
try:
comment_id = ObjectId(comment_id)
except Exception:
raise ErrNoResult("Comment not found")

comment = collection.find_one({'_id': comment_id})
if not comment:
Expand Down Expand Up @@ -154,7 +157,10 @@ def comment_set_spoiler(comment_id, is_spoiler):
collection = get_collection('comment')

if isinstance(comment_id, str):
comment_id = ObjectId(comment_id)
try:
comment_id = ObjectId(comment_id)
except Exception:
raise ErrNoResult("Comment not found")

result = collection.find_one_and_update(
{'_id': comment_id},
Expand All @@ -166,3 +172,73 @@ def comment_set_spoiler(comment_id, is_spoiler):
raise ErrNoResult("Comment not found")

return result


def comment_update(comment_id, content):
"""Update a comment's content.

Args:
comment_id: The comment's ObjectId (string or ObjectId)
content: The new comment content

Returns:
Updated comment document

Raises:
ErrNoResult: If comment not found
"""
if not check_connection():
raise ErrUnavailable("Database is unavailable")

collection = get_collection('comment')

if isinstance(comment_id, str):
try:
comment_id = ObjectId(comment_id)
except Exception:
raise ErrNoResult("Comment not found")

result = collection.find_one_and_update(
{'_id': comment_id},
{'$set': {
'info': content,
'edited': True,
'updated_at': datetime.utcnow()
}},
return_document=ReturnDocument.AFTER
)

if not result:
raise ErrNoResult("Comment not found")

return result


def comment_delete(comment_id):
"""Delete a comment by its ID.

Args:
comment_id: The comment's ObjectId (string or ObjectId)

Returns:
Deleted comment document

Raises:
ErrNoResult: If comment not found
"""
if not check_connection():
raise ErrUnavailable("Database is unavailable")

collection = get_collection('comment')

if isinstance(comment_id, str):
try:
comment_id = ObjectId(comment_id)
except Exception:
raise ErrNoResult("Comment not found")

comment = collection.find_one_and_delete({'_id': comment_id})
if not comment:
raise ErrNoResult("Comment not found")

return comment
59 changes: 57 additions & 2 deletions templates/crackme/read.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@
}
}

function openEditCommentModal(commentId) {
var raw = document.getElementById('comment-raw-' + commentId);
var textarea = document.getElementById('edit-comment-text');
var form = document.getElementById('form-edit-comment');
if (raw && textarea && form) {
textarea.value = raw.value;
form.action = '/comment/' + commentId + '/edit';
openModal('modal-edit-comment');
}
}

function closeModal() {
document.querySelectorAll('.modal.active').forEach(function(m) {
m.classList.remove('active');
Expand Down Expand Up @@ -320,8 +331,29 @@ <h3><a href="/user/{{ username }}">{{ username }}</a>'s {{ name }}</h3>
{% endif %}
{% for comment in comments %}
<div style="margin-bottom: 1em;">
{% if usersess == username or (usersess == comment.author and not comment.spoiler) %}<form action="/comment/{{ comment._id }}/spoiler" method="post" style="display: inline; float: right;"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button type="submit" class="btn btn-link btn-sm" title="{{ 'Remove spoiler mark' if comment.spoiler else 'Mark as spoiler' }}" style="padding: 0;{% if comment.spoiler %} color: #e85600;{% else %} opacity: 0.4;{% endif %}"><i class="icon icon-flag"></i></button></form>{% endif %}
<a href="/user/{{ comment.author }}">{{ comment.author }}</a> on {{ comment.created_at|PRETTYTIME }}:
<div style="float: right; display: flex; gap: 8px; align-items: center;">
{% if usersess == comment.author %}
<button type="button" class="btn btn-link btn-sm" title="Edit comment" onclick="openEditCommentModal('{{ comment._id }}')" style="padding: 0; opacity: 0.5;">
<i class="icon icon-edit"></i>
</button>
<form action="/comment/{{ comment._id }}/delete" method="post" style="display: inline; margin: 0;" onsubmit="return confirm('Are you sure you want to delete this comment?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-link btn-sm text-error" title="Delete comment" style="padding: 0; opacity: 0.5;">
<i class="icon icon-delete"></i>
</button>
</form>
{% endif %}
{% if usersess == username or (usersess == comment.author and not comment.spoiler) %}
<form action="/comment/{{ comment._id }}/spoiler" method="post" style="display: inline; margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-link btn-sm" title="{{ 'Remove spoiler mark' if comment.spoiler else 'Mark as spoiler' }}" style="padding: 0;{% if comment.spoiler %} color: #e85600;{% else %} opacity: 0.4;{% endif %}">
<i class="icon icon-flag"></i>
</button>
</form>
{% endif %}
</div>
<textarea id="comment-raw-{{ comment._id }}" style="display: none;">{{ comment.info }}</textarea>
<a href="/user/{{ comment.author }}">{{ comment.author }}</a> on {{ comment.created_at|PRETTYTIME }}{% if comment.edited or comment.updated_at %} <span class="text-gray" style="font-size: 0.85em;" title="Edited{% if comment.updated_at %} on {{ comment.updated_at|PRETTYTIME }}{% endif %}">(edited)</span>{% endif %}:
{% if comment.spoiler %}
<span id="spoiler-hidden-{{ comment._id }}" class="spoiler-hidden" onclick="revealSpoiler('{{ comment._id }}')">[Click to reveal]</span><span id="spoiler-content-{{ comment._id }}" style="display: none; white-space: pre-line">{{ comment.info|render_mentions }}</span>{% else %}<span style="white-space: pre-line">{{ comment.info|render_mentions }}</span>{% endif %}
</div>
Expand Down Expand Up @@ -386,6 +418,29 @@ <h3><a href="/user/{{ username }}">{{ username }}</a>'s {{ name }}</h3>
</div>
{% endif %}

{% if not auto_validation %}
<div class="modal" id="modal-edit-comment">
<a href="#" onclick="closeModal(); return false;" class="modal-overlay" aria-label="Close"></a>
<div class="modal-container">
<div class="modal-header">
<a href="#" onclick="closeModal(); return false;" class="btn btn-clear float-right" aria-label="Close"></a>
<div class="modal-title h5">Edit comment</div>
</div>
<div class="modal-body">
<div class="content">
<form id="form-edit-comment" action="" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div style="position: relative;">
<textarea name="comment" id="edit-comment-text" placeholder="Edit your comment..." style="width: 100%;" rows="5"></textarea>
</div>
<input type="submit" class="btn active float-right" value="Save changes" style="margin-top: 10px;">
</form>
</div>
</div>
</div>
</div>
{% endif %}

{% if not auto_validation %}
<div class="modal" id="rate-diff">
<a href="#" onclick="closeModal(); return false;" class="modal-overlay" aria-label="Close"></a>
Expand Down
Loading