commennt sanitization

This commit is contained in:
Hamid Reza Mohammadi 2026-09-26 21:32:02 +03:30
parent 748b4b5b0a
commit ab254fefd5
14 changed files with 360 additions and 34 deletions

View File

@ -239,10 +239,14 @@ namespace GanjooRazor.Pages
}); });
} }
string rawError = await ReadErrorMessageAsync(response);
var sanitizerInfo = TryParseSanitizerTextDroppedError(rawError);
return Partial("~/Pages/Partials/GanjoorPage/_CommentPartial.cshtml", new _CommentPartialModel() return Partial("~/Pages/Partials/GanjoorPage/_CommentPartial.cshtml", new _CommentPartialModel()
{ {
Comment = null, Comment = null,
Error = await ReadErrorMessageAsync(response), Error = sanitizerInfo != null ? sanitizerInfo.Message : rawError,
SanitizerRemainingText = sanitizerInfo?.RemainingText,
InReplyTo = null, InReplyTo = null,
LoggedIn = !string.IsNullOrEmpty(Request.Cookies["Token"]), LoggedIn = !string.IsNullOrEmpty(Request.Cookies["Token"]),
PoemId = poemId, PoemId = poemId,
@ -270,7 +274,7 @@ namespace GanjooRazor.Pages
var response = await secureClient.PutAsync($"{APIRoot.Url}/api/ganjoor/comment/{id}", new StringContent(JsonConvert.SerializeObject(comment), Encoding.UTF8, "application/json")); var response = await secureClient.PutAsync($"{APIRoot.Url}/api/ganjoor/comment/{id}", new StringContent(JsonConvert.SerializeObject(comment), Encoding.UTF8, "application/json"));
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
return new BadRequestObjectResult(await ReadErrorMessageAsync(response)); return await BadRequestFromApiErrorAsync(response);
} }
return new JsonResult(true); return new JsonResult(true);
}); });
@ -1145,7 +1149,7 @@ namespace GanjooRazor.Pages
var response = await secureClient.PutAsync($"{APIRoot.Url}/api/ganjoor/bookmark/{id}", new StringContent(JsonConvert.SerializeObject(note), Encoding.UTF8, "application/json")); var response = await secureClient.PutAsync($"{APIRoot.Url}/api/ganjoor/bookmark/{id}", new StringContent(JsonConvert.SerializeObject(note), Encoding.UTF8, "application/json"));
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
return new BadRequestObjectResult(await ReadErrorMessageAsync(response)); return await BadRequestFromApiErrorAsync(response);
} }
return new JsonResult(true); return new JsonResult(true);
}); });

View File

@ -49,6 +49,41 @@ namespace GanjooRazor.Pages
return JsonConvert.DeserializeObject<string>(await response.Content.ReadAsStringAsync()); return JsonConvert.DeserializeObject<string>(await response.Content.ReadAsStringAsync());
} }
/// <summary>
/// Shape of the special error string the API sends (still just a plain string, see
/// GanjoorService._BuildSanitizerDroppedTextError on the RMuseum side) when a comment/note/
/// suggestion was rejected because sanitizing it had to drop real text. Message is the
/// human-readable explanation; RemainingText is the plain text that would have remained,
/// so the client can diff it against what the user actually typed and show exactly what
/// would have been dropped.
/// </summary>
protected class SanitizerTextDroppedInfo
{
public bool SanitizerTextDropped { get; set; }
public string Message { get; set; }
public string RemainingText { get; set; }
}
/// <summary>
/// Returns the parsed <see cref="SanitizerTextDroppedInfo"/> if rawErrorMessage is that
/// special shape, or null for any ordinary plain-text error (including when rawErrorMessage
/// isn't JSON at all, which is the common case).
/// </summary>
protected static SanitizerTextDroppedInfo TryParseSanitizerTextDroppedError(string rawErrorMessage)
{
if (string.IsNullOrWhiteSpace(rawErrorMessage) || rawErrorMessage.TrimStart()[0] != '{')
return null;
try
{
var info = JsonConvert.DeserializeObject<SanitizerTextDroppedInfo>(rawErrorMessage);
return (info != null && info.SanitizerTextDropped) ? info : null;
}
catch
{
return null;
}
}
/// <summary> /// <summary>
/// Runs <paramref name="operation"/> against an HttpClient authenticated from the current /// Runs <paramref name="operation"/> against an HttpClient authenticated from the current
/// session cookies (via <see cref="GanjoorSessionChecker.PrepareClient"/>). If the session /// session cookies (via <see cref="GanjoorSessionChecker.PrepareClient"/>). If the session
@ -61,6 +96,29 @@ namespace GanjooRazor.Pages
/// (rather than a bare 400) should keep their own using/PrepareClient block instead - wrapping /// (rather than a bare 400) should keep their own using/PrepareClient block instead - wrapping
/// those here would silently change what the browser shows on a real (non-AJAX) form submit. /// those here would silently change what the browser shows on a real (non-AJAX) form submit.
/// </summary> /// </summary>
/// <summary>
/// Builds the BadRequest to return for an AJAX handler from a failed API response: the
/// plain error string as before for an ordinary error, or - when it's the "sanitizing had
/// to drop real text" case - a small JSON object ({ sanitizerTextDropped, message,
/// remainingText }) so the client's error callback can show the user what got dropped
/// instead of just displaying raw text.
/// </summary>
protected static async Task<IActionResult> BadRequestFromApiErrorAsync(HttpResponseMessage response)
{
string rawError = await ReadErrorMessageAsync(response);
var sanitizerInfo = TryParseSanitizerTextDroppedError(rawError);
if (sanitizerInfo != null)
{
return new BadRequestObjectResult(new
{
sanitizerTextDropped = true,
message = sanitizerInfo.Message,
remainingText = sanitizerInfo.RemainingText
});
}
return new BadRequestObjectResult(rawError);
}
protected async Task<IActionResult> WithSecureClientAsync( protected async Task<IActionResult> WithSecureClientAsync(
Func<HttpClient, Task<IActionResult>> operation, Func<HttpClient, Task<IActionResult>> operation,
IActionResult unauthorizedResult = null) IActionResult unauthorizedResult = null)

View File

@ -61,6 +61,7 @@
var form = $(this); var form = $(this);
var url = form.attr('action'); var url = form.attr('action');
var originalHtml = form.find('textarea').val();
$.ajax({ $.ajax({
type: "POST", type: "POST",
@ -72,10 +73,18 @@
$(buttonSelector).prop("disabled", false); $(buttonSelector).prop("disabled", false);
}, },
success: function(data) { success: function(data) {
$(data).appendTo(parent1); var $rendered = $(data);
var hadAnyError = $rendered.is('#comment-error');
var hadSanitizerError = checkAppendedHtmlForSanitizerError($rendered, originalHtml);
if (!hadSanitizerError) {
$rendered.appendTo(parent1);
}
$(buttonSelector).text('پیشنهاد'); $(buttonSelector).text('پیشنهاد');
$(buttonSelector).prop("disabled", false); $(buttonSelector).prop("disabled", false);
form[0].reset(); if (!hadAnyError) {
form[0].reset();
}
}, },
}); });

View File

@ -113,7 +113,7 @@ namespace GanjooRazor.Pages
return Page(); return Page();
} }
private IActionResult SpecLineErrorPartial(string error) private IActionResult SpecLineErrorPartial(string error, string remainingText = null)
{ {
return Partial("_PoetSpecLinePartial", new _PoetSpecLinePartialModel() return Partial("_PoetSpecLinePartial", new _PoetSpecLinePartialModel()
{ {
@ -121,7 +121,8 @@ namespace GanjooRazor.Pages
{ {
Id = 0, Id = 0,
Contents = error Contents = error
} },
SanitizerRemainingText = remainingText
}); });
} }
@ -155,7 +156,9 @@ namespace GanjooRazor.Pages
}); });
} }
return SpecLineErrorPartial(await ReadErrorMessageAsync(response)); string rawError = await ReadErrorMessageAsync(response);
var sanitizerInfo = TryParseSanitizerTextDroppedError(rawError);
return SpecLineErrorPartial(sanitizerInfo != null ? sanitizerInfo.Message : rawError, sanitizerInfo?.RemainingText);
}, SpecLineErrorPartial(NotLoggedInMessage)); }, SpecLineErrorPartial(NotLoggedInMessage));
} }

View File

@ -3,8 +3,15 @@
Layout = null; Layout = null;
} }
<div class="ganjoor-comment" id="@(Model.Line.Id == 0 ? "comment-error" : $"line-{Model.Line.Id}")"> <div class="ganjoor-comment" id="@(Model.Line.Id == 0 ? "comment-error" : $"line-{Model.Line.Id}")" data-sanitizer-remaining-text="@(Model.Line.Id == 0 ? Model.SanitizerRemainingText : null)">
@Html.Raw(Model.Line.Contents) @if (Model.Line.Id == 0)
{
@Model.Line.Contents
}
else
{
@Html.Raw(Model.Line.Contents)
}
</div> </div>
@if (Model.Line.Id != 0 && !Model.Line.Published) @if (Model.Line.Id != 0 && !Model.Line.Published)

View File

@ -7,5 +7,12 @@ namespace GanjooRazor.Pages
{ {
public bool ModeratePoetPhotos { get; set; } public bool ModeratePoetPhotos { get; set; }
public GanjoorPoetSuggestedSpecLineViewModel Line { get; set; } public GanjoorPoetSuggestedSpecLineViewModel Line { get; set; }
/// <summary>
/// set (alongside Line.Contents holding the error message, when Line.Id == 0) when the
/// error is the "sanitizing had to drop real text" case - see
/// GanjooRazor.Pages._CommentPartialModel.SanitizerRemainingText for the same pattern.
/// </summary>
public string SanitizerRemainingText { get; set; }
} }
} }

View File

@ -6,9 +6,9 @@
@if (Model.Comment == null) @if (Model.Comment == null)
{ {
<div class="item" id="comment-error"> <div class="item" id="comment-error" data-sanitizer-remaining-text="@Model.SanitizerRemainingText">
<p style="color:red">خطا</p> <p style="color:red">خطا</p>
@Html.Raw(Model.Error) @Model.Error
</div> </div>
} }
else else

View File

@ -7,6 +7,13 @@ namespace GanjooRazor.Pages
{ {
public GanjoorCommentSummaryViewModel Comment { get; set; } public GanjoorCommentSummaryViewModel Comment { get; set; }
public string Error { get; set; } public string Error { get; set; }
/// <summary>
/// set (alongside Error) when Error is the "sanitizing had to drop real text" case -
/// the plain text that would remain, so client JS can diff it against what the user
/// actually typed and show them exactly what got dropped. Null/empty for any other error.
/// </summary>
public string SanitizerRemainingText { get; set; }
public GanjoorCommentSummaryViewModel InReplyTo { get; set; } public GanjoorCommentSummaryViewModel InReplyTo { get; set; }
public bool LoggedIn { get; set; } public bool LoggedIn { get; set; }
public string DivSuffix { get; set; } public string DivSuffix { get; set; }
@ -15,7 +22,7 @@ namespace GanjooRazor.Pages
{ {
get get
{ {
return InReplyTo == null ? "äæÔÊå" : "<22>ÇÓÎ ÏÇÏå"; return InReplyTo == null ? "نوشته" : "پاسخ داده";
} }
} }
public bool Bookmarked { get; set; } public bool Bookmarked { get; set; }

View File

@ -1,4 +1,160 @@
// From David Flanagan's "JavaScript: The Definitive Guide" 5th Ed, // --- Sanitizer "text was dropped" feedback ----------------------------------------------
// Shared by every place a TinyMCE-edited field (a comment, a bookmark note, a suggested
// poet spec-line, ...) gets rejected because the server's HTML sanitizer had to drop real
// text (see GanjoorService._BuildSanitizerDroppedTextError on the API side). Instead of just
// showing a generic error, this shows the user - highlighted, using the same diffChars utility
// already used elsewhere in this file for comparing correction blocks - exactly which part of
// what they typed would be dropped and why. That's almost always because they pasted the text
// in from somewhere else (Word, a chat app, a web page) that silently carried invalid/broken
// formatting along with it, so the message says that rather than talking about "tags" or the
// "<"/">" characters ordinary users don't recognize and have usually never typed themselves.
function _htmlToPlainText(html) {
var tmp = document.createElement('div');
tmp.innerHTML = html || '';
return (tmp.textContent || tmp.innerText || '').replace(/\s+/g, ' ').trim();
}
function _ensureJsDiffLoaded(callback) {
if (window.JsDiff) {
callback();
return;
}
var script = document.createElement('script');
script.src = '/lib/diff.js';
script.onload = callback;
document.head.appendChild(script);
}
function showSanitizerTextDroppedPopup(message, originalHtml, remainingPlainText) {
_ensureJsDiffLoaded(function () {
var originalPlainText = _htmlToPlainText(originalHtml);
var diff = JsDiff.diffChars(originalPlainText, remainingPlainText || '');
var fragment = document.createDocumentFragment();
for (var i = 0; i < diff.length; i++) {
if (diff[i].added) {
continue; // sanitizing only removes content, nothing to highlight as "added"
}
var node;
if (diff[i].removed) {
node = document.createElement('del');
node.style.color = 'red';
node.appendChild(document.createTextNode(diff[i].value));
} else {
node = document.createTextNode(diff[i].value);
}
fragment.appendChild(node);
}
var existing = document.getElementById('sanitizer-warning-modal');
if (existing) {
existing.parentNode.removeChild(existing);
}
var isDark = (window.matchMedia("(prefers-color-scheme: dark)").matches && localStorage.getItem("scheme") != "light") || localStorage.getItem("scheme") == "dark";
var overlay = document.createElement('div');
overlay.id = 'sanitizer-warning-modal';
overlay.style.cssText = 'display:block;position:fixed;z-index:9999;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgba(0,0,0,0.5);';
overlay.onclick = function (e) {
if (e.target === overlay) {
overlay.parentNode.removeChild(overlay);
}
};
var box = document.createElement('div');
box.style.cssText = 'margin:8% auto;padding:20px;max-width:600px;border-radius:8px;direction:rtl;text-align:right;' +
(isDark ? 'background-color:#222;color:#eee;' : 'background-color:#fff;color:#000;');
var messageP = document.createElement('p');
messageP.textContent = message;
box.appendChild(messageP);
if (fragment.childNodes.length > 0) {
var diffTitle = document.createElement('p');
var strong = document.createElement('strong');
strong.textContent = 'بخشی که حذف خواهد شد با رنگ قرمز و خط‌خورده مشخص شده است:';
diffTitle.appendChild(strong);
box.appendChild(diffTitle);
var diffBox = document.createElement('div');
diffBox.style.cssText = 'border:1px solid #999;padding:10px;border-radius:4px;margin-bottom:12px;white-space:pre-wrap;';
diffBox.appendChild(fragment);
box.appendChild(diffBox);
}
var closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.textContent = 'بستن';
closeBtn.onclick = function () {
overlay.parentNode.removeChild(overlay);
};
box.appendChild(closeBtn);
overlay.appendChild(box);
document.body.appendChild(overlay);
});
}
// Parses a raw error payload (jQuery's xhr.responseText, or the text of a server-rendered
// error block) looking for the sanitizer-text-dropped shape. Returns the parsed
// {message, remainingText} or null for any ordinary error (including plain, non-JSON text,
// which is the common case).
function _parseSanitizerDroppedTextError(raw) {
if (typeof raw !== 'string' || raw.length === 0) return null;
try {
var obj = JSON.parse(raw);
if (obj && typeof obj === 'object' && obj.sanitizerTextDropped === true) {
return obj;
}
} catch (e) {
// not JSON - an ordinary plain-text error, nothing to do here
}
return null;
}
// For AJAX (BadRequestObjectResult) handlers: xhr is jQuery's jqXHR from an error callback.
// originalHtml is the HTML the user actually submitted (for the diff). Returns true if it
// showed the popup (nothing else to do), or false for an ordinary error (caller's normal
// error handling, if any, still applies).
function tryShowSanitizerErrorFromXhr(xhr, originalHtml) {
var parsed = _parseSanitizerDroppedTextError(xhr && xhr.responseText);
if (parsed == null) return false;
showSanitizerTextDroppedPopup(parsed.message, originalHtml, parsed.remainingText);
return true;
}
// For server-rendered-partial handlers (postComment, postReplyComment, suggestNote): call this
// on the just-received (not yet necessarily inserted, though it's fine either way) root
// element of the rendered partial. If it carries the sanitizer-drop marker, this removes that
// error block from it (the popup below covers it, so leaving the same message sitting inline
// in the page too would just be clutter) and shows the popup. originalHtml is the HTML the user
// actually submitted (for the diff). Returns true if it handled a sanitizer-drop error.
// $rendered is the jQuery-wrapped set from $(data) for a just-received server-rendered
// partial (postComment, postReplyComment, suggestNote) - using jQuery's own filter/find here
// rather than indexing into the raw DOM nodes is what makes this robust to Razor's rendered
// HTML having a leading/trailing whitespace text node ahead of the actual element, which is
// common and would otherwise make a plain "first DOM node" check miss the real element.
// Returns true when the error div IS the partial's root element (true for every partial that
// currently marks it this way) - callers should skip inserting $rendered into the page at all
// in that case, since the popup this shows already covers the same message plus the diff.
function checkAppendedHtmlForSanitizerError($rendered, originalHtml) {
var $rootMatch = $rendered.filter('[data-sanitizer-remaining-text]');
var $errorDiv = $rootMatch.length > 0 ? $rootMatch : $rendered.find('[data-sanitizer-remaining-text]');
if ($errorDiv.length === 0) return false;
var remainingText = $errorDiv.attr('data-sanitizer-remaining-text');
if (!remainingText) return false;
var message = $errorDiv.text().trim();
if ($rootMatch.length === 0) {
$errorDiv.remove();
}
showSanitizerTextDroppedPopup(message, originalHtml, remainingText);
return true;
}
// From David Flanagan's "JavaScript: The Definitive Guide" 5th Ed,
// http://www.davidflanagan.com/javascript5/display.php?n=15-4&f=15/04.js // http://www.davidflanagan.com/javascript5/display.php?n=15-4&f=15/04.js
//modified 4 ganjoor a little bit //modified 4 ganjoor a little bit
@ -812,13 +968,17 @@ function savePrivateNote() {
var url = '?handler=BookmarkNote'; var url = '?handler=BookmarkNote';
var bookmarkId = $("#editbookmarkId").val(); var bookmarkId = $("#editbookmarkId").val();
var originalHtml = $("textarea#editNoteText").val();
$.ajax({ $.ajax({
type: "PUT", type: "PUT",
url: url, url: url,
data: { data: {
id: bookmarkId, id: bookmarkId,
note: $("textarea#editNoteText").val() note: originalHtml
},
error: function (xhr) {
tryShowSanitizerErrorFromXhr(xhr, originalHtml);
}, },
success: function () { success: function () {
document.getElementById('bookmark-note-dialog').style.display = 'none'; document.getElementById('bookmark-note-dialog').style.display = 'none';
@ -997,6 +1157,8 @@ function postComment(coupletIndex, buttonSelector) {
var form = $(this); var form = $(this);
var url = form.attr('action'); var url = form.attr('action');
var originalHtml = form.find('textarea').val();
$.ajax({ $.ajax({
type: "POST", type: "POST",
url: url, url: url,
@ -1006,12 +1168,20 @@ function postComment(coupletIndex, buttonSelector) {
$(buttonSelector).prop("disabled", false); $(buttonSelector).prop("disabled", false);
}, },
success: function (data) { success: function (data) {
$(data).appendTo(parent1); var $rendered = $(data);
if (parent2 != null) var hadAnyError = $rendered.is('#comment-error');
$(data).appendTo(parent2); var hadSanitizerError = checkAppendedHtmlForSanitizerError($rendered, originalHtml);
if (!hadSanitizerError) {
$rendered.appendTo(parent1);
if (parent2 != null)
$rendered.clone(true).appendTo(parent2);
}
$(buttonSelector).text('درج حاشیه'); $(buttonSelector).text('درج حاشیه');
$(buttonSelector).prop("disabled", false); $(buttonSelector).prop("disabled", false);
form[0].reset(); if (!hadAnyError) {
form[0].reset();
}
}, },
}); });
@ -1043,6 +1213,7 @@ function postReplyComment() {
var form = $(this); var form = $(this);
var url = form.attr('action'); var url = form.attr('action');
var originalHtml = form.find('textarea').val();
$.ajax({ $.ajax({
type: "POST", type: "POST",
@ -1053,10 +1224,18 @@ function postReplyComment() {
$('#replycomment').prop("disabled", false); $('#replycomment').prop("disabled", false);
}, },
success: function (data) { success: function (data) {
document.getElementById('id03').style.display = 'none'; var $rendered = $(data);
$(data).appendTo(parent); var hadAnyError = $rendered.is('#comment-error');
var hadSanitizerError = checkAppendedHtmlForSanitizerError($rendered, originalHtml);
if (!hadAnyError) {
document.getElementById('id03').style.display = 'none';
$("#replycommentform")[0].reset();
}
if (!hadSanitizerError) {
$rendered.appendTo(parent);
}
$('#replycomment').prop("disabled", false); $('#replycomment').prop("disabled", false);
$("#replycommentform")[0].reset();
}, },
}); });
@ -1157,14 +1336,17 @@ function editComment() {
var coupletIndex = $("#editCommentCoupletIndex").val(); var coupletIndex = $("#editCommentCoupletIndex").val();
var originalHtml = $("textarea#editCommentText").val();
$.ajax({ $.ajax({
type: "PUT", type: "PUT",
url: url, url: url,
data: { data: {
id: commentId, id: commentId,
comment: $("textarea#editCommentText").val() comment: originalHtml
}, },
error: function () { error: function (xhr) {
tryShowSanitizerErrorFromXhr(xhr, originalHtml);
$('#editcomment').text('ویرایش حاشیه'); $('#editcomment').text('ویرایش حاشیه');
$('#editcomment').prop("disabled", false); $('#editcomment').prop("disabled", false);
}, },

View File

@ -21113,12 +21113,31 @@
or an actually-disallowed tag such as script/style whose whole subtree is removed) or an actually-disallowed tag such as script/style whose whole subtree is removed)
</summary> </summary>
</member> </member>
<member name="F:RMuseum.Services.Implementation.GanjoorService._sanitizerTextDroppedMessage">
<summary>
shown to the user (via _BuildSanitizerDroppedTextError) when sanitizing had to drop
real text. Deliberately says nothing about "tags", "&lt;" or "&gt;" - ordinary users
don't know what those are and have usually never typed one themselves; almost always
this happens because they pasted the text in from somewhere else (Word, a chat app, a
web page) that silently carried invalid/broken formatting along with it.
</summary>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorService._BuildSanitizerDroppedTextError(System.String)">
<summary>
builds the error string returned to the client when sanitizing dropped real text.
This is a JSON object encoded as a string (not a status-code/contract change) so it
still flows through every existing "the API error is just a display string" code path
unchanged, while GanjooRazor can additionally recognize and unpack it to show the
user exactly what got dropped (see SanitizerTextDroppedInfo on the GanjooRazor side)
</summary>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorService._ProcessCommentHtml(System.String,RMuseum.DbContext.RMuseumDbContext)"> <member name="M:RMuseum.Services.Implementation.GanjoorService._ProcessCommentHtml(System.String,RMuseum.DbContext.RMuseumDbContext)">
<summary> <summary>
sanitizes comment HTML; TextWasDropped is true when the sanitizing process removed sanitizes comment HTML; TextWasDropped is true when the sanitizing process removed
a meaningful chunk of the user's actual text (see _CommentSanitizationDroppedText) - a meaningful chunk of the user's actual text (see _CommentSanitizationDroppedText) -
callers should not silently save Html in that case, but ask the user to fix their callers should not silently save Html in that case, but ask the user to fix their
markup instead markup instead (RemainingPlainText/_BuildSanitizerDroppedTextError let them show what
was dropped)
</summary> </summary>
</member> </member>
<member name="M:RMuseum.Services.Implementation.GanjoorService.FindAndFixLongUrlsInComments"> <member name="M:RMuseum.Services.Implementation.GanjoorService.FindAndFixLongUrlsInComments">

View File

@ -220,7 +220,7 @@ namespace RMuseum.Services.Implementation
var processedNote = await _ProcessCommentHtml(note, _context); var processedNote = await _ProcessCommentHtml(note, _context);
if (processedNote.TextWasDropped) if (processedNote.TextWasDropped)
{ {
return new RServiceResult<bool>(false, "بخشی از متن یادداشت شما به دلیل داشتن نشانه‌های HTML نامعتبر یا ناقص (مثلاً علامت‌های «کوچکتر از» یا «بزرگتر از» به‌تنهایی، یا برچسبی که بسته نشده) هنگام پاک‌سازی حذف شد. لطفاً متن را بررسی و اصلاح کنید و دوباره ثبت نمایید."); return new RServiceResult<bool>(false, _BuildSanitizerDroppedTextError(processedNote.RemainingPlainText));
} }
note = processedNote.Html; note = processedNote.Html;
} }

View File

@ -1,6 +1,7 @@
using AngleSharp.Html.Parser; using AngleSharp.Html.Parser;
using Ganss.Xss; using Ganss.Xss;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using RMuseum.DbContext; using RMuseum.DbContext;
using RMuseum.Models.Ganjoor; using RMuseum.Models.Ganjoor;
using RSecurityBackend.Models.Generic; using RSecurityBackend.Models.Generic;
@ -444,13 +445,41 @@ namespace RMuseum.Services.Implementation
return sanitizedPlainText.Length < originalPlainText.Length * 0.95; return sanitizedPlainText.Length < originalPlainText.Length * 0.95;
} }
/// <summary>
/// shown to the user (via _BuildSanitizerDroppedTextError) when sanitizing had to drop
/// real text. Deliberately says nothing about "tags", "&lt;" or "&gt;" - ordinary users
/// don't know what those are and have usually never typed one themselves; almost always
/// this happens because they pasted the text in from somewhere else (Word, a chat app, a
/// web page) that silently carried invalid/broken formatting along with it.
/// </summary>
private const string _sanitizerTextDroppedMessage =
"به‌نظر می‌رسد متنی که ارسال کرده‌اید از جای دیگری (مثلاً Word یا یک صفحهٔ وب) کپی و در اینجا پیست شده و قالب‌بندی پنهان و نامعتبری را با خود آورده است. به همین دلیل بخشی از متن هنگام پاک‌سازی حذف شد؛ بخش حذف‌شده در ادامه با رنگ قرمز و خط‌خورده به شما نشان داده می‌شود. لطفاً متن را در یک ویرایشگر متن ساده (مثل Notepad) پاک‌سازی کنید یا از نو تایپ کنید و سپس دوباره ارسال نمایید.";
/// <summary>
/// builds the error string returned to the client when sanitizing dropped real text.
/// This is a JSON object encoded as a string (not a status-code/contract change) so it
/// still flows through every existing "the API error is just a display string" code path
/// unchanged, while GanjooRazor can additionally recognize and unpack it to show the
/// user exactly what got dropped (see SanitizerTextDroppedInfo on the GanjooRazor side)
/// </summary>
private static string _BuildSanitizerDroppedTextError(string remainingPlainText)
{
return JsonConvert.SerializeObject(new
{
sanitizerTextDropped = true,
message = _sanitizerTextDroppedMessage,
remainingText = remainingPlainText ?? ""
});
}
/// <summary> /// <summary>
/// sanitizes comment HTML; TextWasDropped is true when the sanitizing process removed /// sanitizes comment HTML; TextWasDropped is true when the sanitizing process removed
/// a meaningful chunk of the user's actual text (see _CommentSanitizationDroppedText) - /// a meaningful chunk of the user's actual text (see _CommentSanitizationDroppedText) -
/// callers should not silently save Html in that case, but ask the user to fix their /// callers should not silently save Html in that case, but ask the user to fix their
/// markup instead /// markup instead (RemainingPlainText/_BuildSanitizerDroppedTextError let them show what
/// was dropped)
/// </summary> /// </summary>
private async Task<(string Html, bool TextWasDropped)> _ProcessCommentHtml(string commentText, RMuseumDbContext context) private async Task<(string Html, bool TextWasDropped, string RemainingPlainText)> _ProcessCommentHtml(string commentText, RMuseumDbContext context)
{ {
string originalPlainText = _ExtractPlainText(commentText); string originalPlainText = _ExtractPlainText(commentText);
@ -496,12 +525,13 @@ namespace RMuseum.Services.Implementation
// removed, before Linkify/internal-link processing below can itself change the // removed, before Linkify/internal-link processing below can itself change the
// visible text (e.g. replacing a bare URL's text with a page title) in a way that // visible text (e.g. replacing a bare URL's text with a page title) in a way that
// is not a loss. // is not a loss.
bool textWasDropped = _CommentSanitizationDroppedText(originalPlainText, _ExtractPlainText(sanitizedHtml)); string sanitizedPlainText = _ExtractPlainText(sanitizedHtml);
bool textWasDropped = _CommentSanitizationDroppedText(originalPlainText, sanitizedPlainText);
// Process URLs (Linkify) and internal Ganjoor links // Process URLs (Linkify) and internal Ganjoor links
sanitizedHtml = await _ProcessUrls(sanitizedHtml, context); sanitizedHtml = await _ProcessUrls(sanitizedHtml, context);
return (sanitizedHtml, textWasDropped); return (sanitizedHtml, textWasDropped, sanitizedPlainText);
} }
private async Task<string> _ProcessUrls(string html, RMuseumDbContext context) private async Task<string> _ProcessUrls(string html, RMuseumDbContext context)

View File

@ -167,7 +167,7 @@ namespace RMuseum.Services.Implementation
var processedContents = await _ProcessCommentHtml(model.Contents, _context); var processedContents = await _ProcessCommentHtml(model.Contents, _context);
if (processedContents.TextWasDropped) if (processedContents.TextWasDropped)
{ {
return new RServiceResult<GanjoorPoetSuggestedSpecLineViewModel>(null, "بخشی از متن پیشنهادی شما به دلیل داشتن نشانه‌های HTML نامعتبر یا ناقص (مثلاً علامت‌های «کوچکتر از» یا «بزرگتر از» به‌تنهایی، یا برچسبی که بسته نشده) هنگام پاک‌سازی حذف شد. لطفاً متن را بررسی و اصلاح کنید و دوباره ارسال نمایید."); return new RServiceResult<GanjoorPoetSuggestedSpecLineViewModel>(null, _BuildSanitizerDroppedTextError(processedContents.RemainingPlainText));
} }
model.Contents = processedContents.Html; model.Contents = processedContents.Html;
@ -223,7 +223,7 @@ namespace RMuseum.Services.Implementation
var processedContents = await _ProcessCommentHtml(model.Contents, _context); var processedContents = await _ProcessCommentHtml(model.Contents, _context);
if (processedContents.TextWasDropped) if (processedContents.TextWasDropped)
{ {
return new RServiceResult<bool>(false, "بخشی از متن به دلیل داشتن نشانه‌های HTML نامعتبر یا ناقص (مثلاً علامت‌های «کوچکتر از» یا «بزرگتر از» به‌تنهایی، یا برچسبی که بسته نشده) هنگام پاک‌سازی حذف شد. لطفاً متن را بررسی و اصلاح کنید و دوباره ثبت نمایید."); return new RServiceResult<bool>(false, _BuildSanitizerDroppedTextError(processedContents.RemainingPlainText));
} }
model.Contents = processedContents.Html; model.Contents = processedContents.Html;

View File

@ -1044,7 +1044,7 @@ namespace RMuseum.Services.Implementation
var processedComment = await _ProcessCommentHtml(content, _context); var processedComment = await _ProcessCommentHtml(content, _context);
if (processedComment.TextWasDropped) if (processedComment.TextWasDropped)
{ {
return new RServiceResult<GanjoorCommentSummaryViewModel>(null, "بخشی از متن حاشیهٔ شما به دلیل داشتن نشانه‌های HTML نامعتبر یا ناقص (مثلاً علامت‌های «کوچکتر از» یا «بزرگتر از» به‌تنهایی، یا برچسبی که بسته نشده) هنگام پاک‌سازی حذف شد. لطفاً متن حاشیه را بررسی و اصلاح کنید و دوباره ارسال نمایید."); return new RServiceResult<GanjoorCommentSummaryViewModel>(null, _BuildSanitizerDroppedTextError(processedComment.RemainingPlainText));
} }
content = processedComment.Html; content = processedComment.Html;
@ -1189,7 +1189,7 @@ namespace RMuseum.Services.Implementation
var processedComment = await _ProcessCommentHtml(htmlComment, _context); var processedComment = await _ProcessCommentHtml(htmlComment, _context);
if (processedComment.TextWasDropped) if (processedComment.TextWasDropped)
{ {
return new RServiceResult<bool>(false, "بخشی از متن حاشیهٔ شما به دلیل داشتن نشانه‌های HTML نامعتبر یا ناقص (مثلاً علامت‌های «کوچکتر از» یا «بزرگتر از» به‌تنهایی، یا برچسبی که بسته نشده) هنگام پاک‌سازی حذف شد. لطفاً متن حاشیه را بررسی و اصلاح کنید و دوباره ارسال نمایید."); return new RServiceResult<bool>(false, _BuildSanitizerDroppedTextError(processedComment.RemainingPlainText));
} }
htmlComment = processedComment.Html; htmlComment = processedComment.Html;