diff --git a/Jacaranda_Comments.dnn b/Jacaranda_Comments.dnn index 51fbbb9..63684c0 100644 --- a/Jacaranda_Comments.dnn +++ b/Jacaranda_Comments.dnn @@ -1,9 +1,9 @@ - + - + Jacaranda Comments - Page-level comments and replies module for DNN 10 with opt-in moderated guest posting, registered-author editing, a private per-module language filter, configurable comment length, page-aware moderator email subjects, module-aware accessible notifications, a prominent accessible CAPTCHA answer field, rate limiting, email notifications, and optional CAPTCHA. + Advanced page-level comments and replies module for DNN 10 with one-place portal-wide configuration, central moderation across all module instances, emergency posting controls, moderated guest posting with a secure five-minute correction window, registered-author editing, a private language filter, configurable comment length, page-aware moderator email subjects, accessible module-aware notifications, rate limiting, email notifications, and optional CAPTCHA. Trevor Forrester Forrest It Services @@ -135,6 +135,26 @@ 01.01.03.SqlDataProvider 01.01.03 + + + + + +
+ + Jacaranda Comments administration is restricted to DNN portal administrators and superusers. + + + +

Jacaranda Comments Administration

+

+ Moderate pending comments from every Jacaranda Comments instance in this DNN portal and manage the commenting configuration for the whole portal in one place. +

+ + + + + + + +
+ Pending comments +

+ This queue shows unapproved comments and replies from every Jacaranda Comments module in the current portal. Approve or reject one submission at a time. +

+
+ + + No comments are currently waiting for approval on this portal. + + + + +
+
+
+ <%# PendingPageTitle(Eval("PageTitle"), Eval("TabId")) %> + <%# PendingModuleTitle(Eval("ModuleTitle"), Eval("ModuleId")) %> +
+
+ <%# PendingSubmissionType(Eval("ParentCommentId")) %> #<%# Eval("CommentId") %> + · <%# PendingCreatedDate(Eval("CreatedOnDate")) %> +
+
+ +
+ <%# Server.HtmlEncode(Convert.ToString(Eval("DisplayName"))) %> + <%# PendingAuthorType(Eval("UserId")) %> + +
+ +
<%# PendingCommentBody(Eval("CommentText")) %>
+ +
+ Approve + Reject / Delete + View Page +
+
+
+
+ + + + + + +
+ + +

Central configuration is not yet active

+

+ Existing Advanced 01.02.x modules are still using their stored page-level settings. Review the portal settings below and save them for review before activating central management. +

+

+ The emergency posting switches below remain active immediately, as they did in 01.02.x. Activating central configuration is permanent for the Advanced branch: all Jacaranda-specific settings will then come from this Comments Administration panel. +

+ +
+ + + Central configuration is active. + Every Jacaranda Comments Advanced instance in this portal uses the settings on this page. Stored page-level Jacaranda settings are retained for safety but are ignored. + + +
+ Emergency controls + +
+ +

+ Clearing this switch immediately disables all new comments, replies, and author correction/edit actions across the portal. Existing comments remain visible and moderators can still approve or delete them. +

+
+ +
+ +

+ Clearing this switch disables guest posting and any still-open guest correction window across every module, regardless of the normal guest setting below. Registered-user posting is unaffected. +

+
+
+ +
+ Comment settings +

+ After central configuration is activated, these values apply to every Jacaranda Comments Advanced instance in this portal. +

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +

Enter one private term or phrase per line. Maximum 250 entries and 100 characters per entry.

+
+ +
+ + + + +
+
+ +
+ Rate limiting +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ CAPTCHA +
+ +
+
+ +
+ Email notifications +
+ +
+
+ + +

Separate multiple addresses with commas or semicolons. Leave blank to use the portal email address.

+
+
+ +
+
+ +
+ +
+ + +
+
+
diff --git a/Resources/Settings.ascx b/Resources/Settings.ascx index 7d247a3..aeb811b 100644 --- a/Resources/Settings.ascx +++ b/Resources/Settings.ascx @@ -1,14 +1,11 @@ <%@ Control Language="C#" AutoEventWireup="true" Inherits="DotNetNuke.Entities.Modules.ModuleSettingsBase" %> <%@ Import Namespace="System" %> -<%@ Import Namespace="System.Collections.Generic" %> -<%@ Import Namespace="DotNetNuke.Entities.Modules" %> +<%@ Import Namespace="System.Data" %> +<%@ Import Namespace="System.Data.SqlClient" %> +<%@ Import Namespace="DotNetNuke.Common.Utilities" %> +<%@ Import Namespace="DotNetNuke.Data" %>
-

Jacaranda Comments Settings

- -
- Guest commenting - -
- -

- Default: off. Guest name and email are required, the email is never shown publicly, and every guest submission is held for approval. Guests cannot edit after submitting; they must register or sign in before posting to receive the 15-minute edit window. -

-

- For public guest commenting, enable CAPTCHA and keep rate limiting enabled. You can turn guest commenting off immediately without affecting existing comments. -

-
-
- -
- Moderation - -
- -

- Editors, administrators, and superusers can still post immediately and approve/delete comments from the module view. -

-
-
- -
- Language filter - -
- -

- Default: off. A matching submission is kept unchanged but forced into moderation. The visitor is only told that the submission is waiting for approval. -

-
- -
- - -

- Enter one term or phrase per line. Matching is not case-sensitive and treats punctuation as a separator. The list is available only on this authorised settings screen and is not sent to the public comment form. Up to 250 entries are retained, with a maximum of 100 characters per entry. -

-

- This is a moderation aid, not a complete content-safety system. Review flagged submissions before approval because simple language filters can produce false matches or be deliberately evaded. -

-
-
- -
- Comment length - -
- - - - -

- Default: 4,000 characters. This setting applies to both comments and replies and is stored separately for each module instance. -

-
-
- -
- Rate limiting - -
- -
+

Jacaranda Comments Advanced Settings

-
-
- - -
+
+ Central administration -
- - -
+

+ Jacaranda Comments Advanced 01.03.00 manages its commenting configuration in one place for the whole DNN portal. Page-level Jacaranda settings are no longer edited here. +

-
- - -
-
+

- Default: 1 minute between posts and 5 posts per 15 minutes. Moderators are not rate-limited. + Existing local Jacaranda module settings are retained in DNN for migration safety, but after central configuration is activated they are ignored by the Advanced edition. DNN's normal module title, container, visibility and permission settings remain managed by DNN itself.

-
-
- CAPTCHA - -
- + +

- This avoids third-party scripts and keys. It applies to registered non-editors and to guests when guest commenting is enabled. + Review portal-wide settings, activate central configuration, moderate pending comments, and manage emergency posting controls here.

-
-
+ -
- Email notifications - -
- -
- -
- - -

- Separate multiple addresses with commas or semicolons. Leave blank to use the portal email address. + +

+ Jacaranda Comments Advanced configuration can be changed only by a DNN portal Administrator or Superuser through Comments Administration.

-
- -
- -
+
- -

- Settings are stored as DNN module settings, so each instance can independently allow or block guest posting and use its own comment length, moderation, notification, and anti-spam behaviour. -

diff --git a/Resources/View.ascx b/Resources/View.ascx index cb9e78a..6ccb71e 100644 --- a/Resources/View.ascx +++ b/Resources/View.ascx @@ -1,4 +1,4 @@ -<%@ Control Language="C#" AutoEventWireup="true" Inherits="DotNetNuke.Entities.Modules.PortalModuleBase" %> +<%@ Control Language="C#" AutoEventWireup="true" Inherits="DotNetNuke.Entities.Modules.PortalModuleBase" %> <%@ Import Namespace="System" %> <%@ Import Namespace="System.Collections.Generic" %> <%@ Import Namespace="System.Data" %> @@ -23,11 +23,13 @@ private const int MaximumGuestDisplayNameLength = 100; private const int MaximumGuestEmailLength = 254; private const int RegisteredEditWindowMinutes = 15; + private const int GuestEditWindowMinutes = 5; private const int MaximumBlockedTermCount = 250; private const int MaximumBlockedTermLength = 100; private const string SettingPrefix = "JacarandaComments_"; private const string CaptchaAnswerViewStateKey = "JacarandaComments_CaptchaAnswer"; private const string SecurityTokenSessionKeyPrefix = "JacarandaComments_SecurityToken_"; + private const string GuestEditTokenSessionKeyPrefix = "JacarandaComments_GuestEditToken_"; private const string PostRedirectMessageSessionKeyPrefix = "JacarandaComments_PostRedirectMessage_"; private const string PostRedirectSuccessSessionKeyPrefix = "JacarandaComments_PostRedirectSuccess_"; private const string PostRedirectTargetCommentSessionKeyPrefix = "JacarandaComments_PostRedirectTargetComment_"; @@ -37,6 +39,9 @@ private const string PostRedirectModuleQueryKey = "jcmid"; private const string PostRedirectCommentQueryKey = "jccid"; + private PortalCommentSettings _portalCommentSettings; + private bool _portalCommentSettingsLoaded; + private string PostRedirectMessageAnchorId { get { return PostRedirectMessageAnchorPrefix + ModuleId; } @@ -54,6 +59,18 @@ } } + private string PortalSettingsTable + { + get + { + var provider = DataProvider.Instance(); + var owner = CleanSqlIdentifierPart(provider.DatabaseOwner, "dbo"); + var qualifier = CleanSqlIdentifierPart(provider.ObjectQualifier, String.Empty); + + return "[" + owner + "].[" + qualifier + "JacarandaCommentsPortalSettings]"; + } + } + private string SecurityTokenSessionKey { get @@ -62,6 +79,14 @@ } } + private string GuestEditTokenSessionKey + { + get + { + return GuestEditTokenSessionKeyPrefix + PortalId + "_" + TabId + "_" + ModuleId; + } + } + private string PostRedirectMessageSessionKey { get @@ -96,14 +121,38 @@ get { return UserInfo != null && UserId > -1 && !UserInfo.IsDeleted; } } + + private bool PortalPostingEnabled + { + get + { + var settings = GetPortalCommentSettings(); + return !settings.Available || settings.PostingEnabled; + } + } + + private bool PortalGuestPostingEnabled + { + get + { + var settings = GetPortalCommentSettings(); + return !settings.Available || settings.GuestPostingEnabled; + } + } + private bool AllowGuestComments { - get { return GetModuleSettingBool("AllowGuestComments", false); } + get + { + var localValue = GetModuleSettingBool("AllowGuestComments", false); + var effectiveValue = GetEffectivePortalBool(localValue, delegate(PortalCommentSettings value) { return value.DefaultAllowGuestComments; }); + return PortalGuestPostingEnabled && effectiveValue; + } } protected bool CanPostComments { - get { return IsRegisteredCommentUser || AllowGuestComments; } + get { return PortalPostingEnabled && (IsRegisteredCommentUser || AllowGuestComments); } } private bool IsGuestPoster @@ -116,71 +165,142 @@ return UserInfo != null && (UserInfo.IsSuperUser || IsEditable); } + private bool CanManagePortalCommentSettings() + { + if (UserInfo == null) + { + return false; + } + + if (UserInfo.IsSuperUser) + { + return true; + } + + var administratorRoleName = PortalSettings == null + ? String.Empty + : (PortalSettings.AdministratorRoleName ?? String.Empty).Trim(); + + return !String.IsNullOrWhiteSpace(administratorRoleName) + && UserInfo.IsInRole(administratorRoleName); + } + private bool RequireApprovalForNonEditors { - get { return GetModuleSettingBool("RequireApprovalForNonEditors", true); } + get + { + var localValue = GetModuleSettingBool("RequireApprovalForNonEditors", true); + return GetEffectivePortalBool(localValue, delegate(PortalCommentSettings value) { return value.DefaultRequireApprovalForNonEditors; }); + } } private bool EnableLanguageFilter { - get { return GetModuleSettingBool("EnableLanguageFilter", false); } + get + { + var localValue = GetModuleSettingBool("EnableLanguageFilter", false); + return GetEffectivePortalBool(localValue, delegate(PortalCommentSettings value) { return value.DefaultEnableLanguageFilter; }); + } } private string BlockedLanguageTerms { - get { return GetModuleSettingString("BlockedLanguageTerms", String.Empty); } + get + { + var localValue = GetModuleSettingString("BlockedLanguageTerms", String.Empty); + return GetEffectivePortalString(localValue, delegate(PortalCommentSettings value) { return value.DefaultBlockedLanguageTerms; }); + } } private int MaximumCommentLength { get { - return GetModuleSettingInt( + var localValue = GetModuleSettingInt( "MaximumCommentLength", DefaultMaximumCommentLength, MinimumMaximumCommentLength, MaximumMaximumCommentLength); + + return GetEffectivePortalInt( + localValue, + delegate(PortalCommentSettings value) { return value.DefaultMaximumCommentLength; }, + DefaultMaximumCommentLength, + MinimumMaximumCommentLength, + MaximumMaximumCommentLength); } } private bool EnableRateLimiting { - get { return GetModuleSettingBool("EnableRateLimiting", true); } + get + { + var localValue = GetModuleSettingBool("EnableRateLimiting", true); + return GetEffectivePortalBool(localValue, delegate(PortalCommentSettings value) { return value.DefaultEnableRateLimiting; }); + } } private int RateLimitSeconds { - get { return GetModuleSettingInt("RateLimitSeconds", 60, 0, 3600); } + get + { + var localValue = GetModuleSettingInt("RateLimitSeconds", 60, 0, 3600); + return GetEffectivePortalInt(localValue, delegate(PortalCommentSettings value) { return value.DefaultRateLimitSeconds; }, 60, 0, 3600); + } } private int RateLimitMaxPosts { - get { return GetModuleSettingInt("RateLimitMaxPosts", 5, 1, 100); } + get + { + var localValue = GetModuleSettingInt("RateLimitMaxPosts", 5, 1, 100); + return GetEffectivePortalInt(localValue, delegate(PortalCommentSettings value) { return value.DefaultRateLimitMaxPosts; }, 5, 1, 100); + } } private int RateLimitWindowMinutes { - get { return GetModuleSettingInt("RateLimitWindowMinutes", 15, 1, 1440); } + get + { + var localValue = GetModuleSettingInt("RateLimitWindowMinutes", 15, 1, 1440); + return GetEffectivePortalInt(localValue, delegate(PortalCommentSettings value) { return value.DefaultRateLimitWindowMinutes; }, 15, 1, 1440); + } } private bool EnableCaptcha { - get { return GetModuleSettingBool("EnableCaptcha", false); } + get + { + var localValue = GetModuleSettingBool("EnableCaptcha", false); + return GetEffectivePortalBool(localValue, delegate(PortalCommentSettings value) { return value.DefaultEnableCaptcha; }); + } } private bool EnableNotifications { - get { return GetModuleSettingBool("EnableNotifications", false); } + get + { + var localValue = GetModuleSettingBool("EnableNotifications", false); + return GetEffectivePortalBool(localValue, delegate(PortalCommentSettings value) { return value.DefaultEnableNotifications; }); + } } private string NotificationEmailAddresses { - get { return GetModuleSettingString("NotificationEmailAddresses", String.Empty); } + get + { + var localValue = GetModuleSettingString("NotificationEmailAddresses", String.Empty); + return GetEffectivePortalString(localValue, delegate(PortalCommentSettings value) { return value.DefaultNotificationEmailAddresses; }); + } } private bool IncludeCommentTextInNotifications { - get { return GetModuleSettingBool("IncludeCommentTextInNotifications", true); } + get + { + var localValue = GetModuleSettingBool("IncludeCommentTextInNotifications", true); + return GetEffectivePortalBool(localValue, delegate(PortalCommentSettings value) { return value.DefaultIncludeCommentTextInNotifications; }); + } } private bool CaptchaAppliesToCurrentUser @@ -293,15 +413,23 @@ { var isRegisteredUser = IsRegisteredCommentUser; var isGuest = IsGuestPoster; + int selectedEditCommentId; + var isGuestEditContext = isGuest && TryGetSelectedEditCommentId(out selectedEditCommentId); pnlCommentForm.Visible = CanPostComments; pnlLoginRequired.Visible = !CanPostComments; - pnlGuestEmail.Visible = isGuest; + litPostingUnavailable.Text = Server.HtmlEncode(GetPostingUnavailableMessage()); + lnkPortalSettings.Visible = CanManagePortalCommentSettings(); + if (lnkPortalSettings.Visible) + { + lnkPortalSettings.NavigateUrl = EditUrl("PortalSettings"); + } + pnlGuestEmail.Visible = isGuest && !isGuestEditContext; pnlGuestNotice.Visible = isGuest; pnlCaptcha.Visible = CaptchaAppliesToCurrentUser; litModerationNote.Text = BuildModerationNote(); - txtDisplayName.ReadOnly = isRegisteredUser; + txtDisplayName.ReadOnly = isRegisteredUser || isGuestEditContext; txtDisplayName.Attributes["autocomplete"] = isGuest ? "name" : "off"; txtDisplayName.Attributes["aria-required"] = isGuest ? "true" : "false"; txtGuestEmail.Attributes["autocomplete"] = "email"; @@ -357,6 +485,7 @@ SELECT CommentId, CommentText, IsApproved, IsLanguageFlagged, + GuestEditTokenHash, CreatedOnDate, EditedOnDate FROM " + CommentsTable + @" @@ -364,7 +493,19 @@ WHERE PortalId = @PortalId AND TabId = @TabId AND ModuleId = @ModuleId AND IsDeleted = 0 - AND (IsApproved = 1 OR @CanModerate = 1 OR (@CurrentUserId > -1 AND UserId = @CurrentUserId)) + AND ( + IsApproved = 1 + OR @CanModerate = 1 + OR (@CurrentUserId > -1 AND UserId = @CurrentUserId) + OR ( + @CurrentUserId < 0 + AND UserId IS NULL + AND IsApproved = 0 + AND @GuestEditTokenHash <> N'' + AND GuestEditTokenHash = @GuestEditTokenHash + AND CreatedOnDate >= DATEADD(MINUTE, -@GuestEditWindowMinutes, GETUTCDATE()) + ) + ) ORDER BY CreatedOnDate ASC;"; command.Parameters.Add("@PortalId", SqlDbType.Int).Value = PortalId; @@ -372,6 +513,9 @@ ORDER BY CreatedOnDate ASC;"; command.Parameters.Add("@ModuleId", SqlDbType.Int).Value = ModuleId; command.Parameters.Add("@CanModerate", SqlDbType.Bit).Value = CanModerateComments(); command.Parameters.Add("@CurrentUserId", SqlDbType.Int).Value = UserId; + command.Parameters.Add("@GuestEditTokenHash", SqlDbType.NVarChar, 64).Value = + IsRegisteredCommentUser ? String.Empty : GetGuestEditTokenHash(false); + command.Parameters.Add("@GuestEditWindowMinutes", SqlDbType.Int).Value = GuestEditWindowMinutes; using (var adapter = new SqlDataAdapter(command)) { @@ -438,7 +582,7 @@ ORDER BY CreatedOnDate ASC;"; if (!CanPostComments) { - ShowMessage("Please sign in before posting a comment.", false); + ShowMessage(GetPostingUnavailableMessage(), false); ConfigureForm(); BindComments(); return; @@ -486,24 +630,17 @@ ORDER BY CreatedOnDate ASC;"; return; } - if (hasEditCommentId && !IsRegisteredCommentUser) - { - ClearEditContext(); - ShowPostingValidationMessage("Guest comments cannot be edited. Register or sign in before posting to receive the 15-minute edit window."); - ConfigureForm(); - BindComments(); - return; - } - var isGuest = IsGuestPoster; + var isGuestEdit = hasEditCommentId && !IsRegisteredCommentUser; var displayName = IsRegisteredCommentUser ? GetCurrentUserDisplayName() : NormalizeSingleLineText(txtDisplayName.Text); var guestEmail = isGuest ? (txtGuestEmail.Text ?? String.Empty).Trim() : String.Empty; var protectedGuestEmail = String.Empty; var guestRateLimitKey = String.Empty; + var guestEditTokenHash = String.Empty; - if (isGuest) + if (isGuest && !isGuestEdit) { if (displayName.Length < MinimumGuestDisplayNameLength) { @@ -542,8 +679,9 @@ ORDER BY CreatedOnDate ASC;"; } guestRateLimitKey = ComputeGuestRateLimitKey(); + guestEditTokenHash = GetGuestEditTokenHash(true); - if (String.IsNullOrWhiteSpace(guestRateLimitKey)) + if (String.IsNullOrWhiteSpace(guestRateLimitKey) || String.IsNullOrWhiteSpace(guestEditTokenHash)) { RestoreFormContextFromHiddenFields(); ShowPostingValidationMessage("A secure guest posting session could not be confirmed. Please refresh the page and try again."); @@ -597,15 +735,36 @@ ORDER BY CreatedOnDate ASC;"; { bool editedCommentIsReply; bool editedCommentIsApproved; + string editedDisplayName = displayName; + string editedGuestEmail = String.Empty; string editError; - if (!TryUpdateOwnComment( - editCommentId, - commentText, - languageFlagged, - out editedCommentIsReply, - out editedCommentIsApproved, - out editError)) + bool editSucceeded; + + if (IsRegisteredCommentUser) + { + editSucceeded = TryUpdateOwnComment( + editCommentId, + commentText, + languageFlagged, + out editedCommentIsReply, + out editedCommentIsApproved, + out editError); + } + else + { + editSucceeded = TryUpdateGuestComment( + editCommentId, + commentText, + languageFlagged, + out editedCommentIsReply, + out editedCommentIsApproved, + out editedDisplayName, + out editedGuestEmail, + out editError); + } + + if (!editSucceeded) { ShowPostingValidationMessage(editError); ConfigureForm(); @@ -617,10 +776,10 @@ ORDER BY CreatedOnDate ASC;"; editCommentId, editedCommentIsReply, editedCommentIsApproved, - displayName, + editedDisplayName, commentText, - false, - String.Empty, + !IsRegisteredCommentUser, + editedGuestEmail, true, languageFlagged); @@ -630,13 +789,17 @@ ORDER BY CreatedOnDate ASC;"; GenerateCaptchaChallenge(); RegisterClearCommentFormScript(); - var editSuccessMessage = editedCommentIsApproved + var editSuccessMessage = !IsRegisteredCommentUser ? (editedCommentIsReply - ? "Your reply changes have been saved. You can refresh the page safely." - : "Your comment changes have been saved. You can refresh the page safely.") - : (editedCommentIsReply - ? "Your reply changes were saved and are waiting for approval. You can refresh the page safely." - : "Your comment changes were saved and are waiting for approval. You can refresh the page safely."); + ? "Your reply correction has been saved and is still waiting for approval." + : "Your comment correction has been saved and is still waiting for approval.") + : (editedCommentIsApproved + ? (editedCommentIsReply + ? "Your reply changes have been saved. You can refresh the page safely." + : "Your comment changes have been saved. You can refresh the page safely.") + : (editedCommentIsReply + ? "Your reply changes were saved and are waiting for approval. You can refresh the page safely." + : "Your comment changes were saved and are waiting for approval. You can refresh the page safely.")); var editStatusCode = editedCommentIsReply ? (editedCommentIsApproved ? "reply-edited" : "reply-edit-pending") @@ -699,6 +862,7 @@ INSERT INTO " + CommentsTable + @" ( DisplayName, GuestEmailEncrypted, GuestRateLimitKey, + GuestEditTokenHash, CommentText, IsApproved, IsLanguageFlagged, @@ -715,6 +879,7 @@ VALUES ( @DisplayName, @GuestEmailEncrypted, @GuestRateLimitKey, + @GuestEditTokenHash, @CommentText, @IsApproved, @IsLanguageFlagged, @@ -743,6 +908,9 @@ SELECT CONVERT(INT, SCOPE_IDENTITY());"; var guestRateParameter = command.Parameters.Add("@GuestRateLimitKey", SqlDbType.NVarChar, 64); guestRateParameter.Value = isGuest ? (object)guestRateLimitKey : DBNull.Value; + var guestEditParameter = command.Parameters.Add("@GuestEditTokenHash", SqlDbType.NVarChar, 64); + guestEditParameter.Value = isGuest ? (object)guestEditTokenHash : DBNull.Value; + command.Parameters.Add("@CommentText", SqlDbType.NVarChar, maximumCommentLength).Value = commentText; command.Parameters.Add("@IsApproved", SqlDbType.Bit).Value = autoApprove; command.Parameters.Add("@IsLanguageFlagged", SqlDbType.Bit).Value = languageFlagged; @@ -771,20 +939,26 @@ SELECT CONVERT(INT, SCOPE_IDENTITY());"; GenerateCaptchaChallenge(); RegisterClearCommentFormScript(); - var successMessage = autoApprove + var successMessage = isGuest ? (parentCommentId.HasValue - ? "Your reply has been posted. You can refresh the page safely." - : "Your comment has been posted. You can refresh the page safely.") - : (parentCommentId.HasValue - ? "Your reply was received and is waiting for approval. You can refresh the page safely." - : "Your comment was received and is waiting for approval. You can refresh the page safely."); + ? "Your reply was received and is waiting for approval. You may correct its text for up to 5 minutes while it remains awaiting approval." + : "Your comment was received and is waiting for approval. You may correct its text for up to 5 minutes while it remains awaiting approval.") + : (autoApprove + ? (parentCommentId.HasValue + ? "Your reply has been posted. You can refresh the page safely." + : "Your comment has been posted. You can refresh the page safely.") + : (parentCommentId.HasValue + ? "Your reply was received and is waiting for approval. You can refresh the page safely." + : "Your comment was received and is waiting for approval. You can refresh the page safely.")); var statusCode = GetPostRedirectStatusCode(parentCommentId.HasValue, autoApprove); - // Registered authors can see their own pending submissions. Guest pending - // submissions remain at the confirmation message because no public identity - // is available after redirect. - var redirectTargetCommentId = !isGuest ? (int?)newCommentId : null; + // Registered authors can see their own pending submissions. Guests receive + // the same limited visibility only while their secure five-minute correction + // credential remains valid and the submission is still pending. + var redirectTargetCommentId = !isGuest || !String.IsNullOrWhiteSpace(guestEditTokenHash) + ? (int?)newCommentId + : null; QueuePostRedirectMessage(successMessage, true, redirectTargetCommentId); @@ -847,9 +1021,9 @@ SELECT CONVERT(INT, SCOPE_IDENTITY());"; if (String.Equals(e.CommandName, "EditComment", StringComparison.OrdinalIgnoreCase)) { - if (!IsRegisteredCommentUser) + if (!PortalPostingEnabled) { - ShowMessage("Guest comments cannot be edited. Register or sign in before posting to receive the 15-minute edit window.", false); + ShowMessage(GetPostingUnavailableMessage(), false); ConfigureForm(); BindComments(); return; @@ -857,12 +1031,16 @@ SELECT CONVERT(INT, SCOPE_IDENTITY());"; string editableCommentText; bool editableCommentIsReply; + bool editableCommentIsGuest; + string editableDisplayName; string editError; if (!TryLoadEditableComment( commentId, out editableCommentText, out editableCommentIsReply, + out editableCommentIsGuest, + out editableDisplayName, out editError)) { ShowMessage(editError, false); @@ -871,7 +1049,7 @@ SELECT CONVERT(INT, SCOPE_IDENTITY());"; return; } - SetEditContext(commentId, editableCommentIsReply, editableCommentText, true); + SetEditContext(commentId, editableCommentIsReply, editableCommentIsGuest, editableDisplayName, editableCommentText, true); ConfigureForm(); EnsureCaptchaChallenge(); BindComments(); @@ -883,7 +1061,7 @@ SELECT CONVERT(INT, SCOPE_IDENTITY());"; { if (!CanPostComments) { - ShowMessage("Please sign in before replying, or enable guest commenting in this module's settings.", false); + ShowMessage(GetPostingUnavailableMessage(), false); ConfigureForm(); BindComments(); return; @@ -958,14 +1136,29 @@ SELECT CONVERT(INT, SCOPE_IDENTITY());"; int commentId, out string commentText, out bool isReply, + out bool isGuestEdit, + out string displayName, out string errorMessage) { commentText = String.Empty; isReply = false; + isGuestEdit = false; + displayName = String.Empty; errorMessage = "That comment cannot be edited."; - if (!IsRegisteredCommentUser || commentId <= 0) + if (!PortalPostingEnabled || commentId <= 0) + { + return false; + } + + var guestEditTokenHash = IsRegisteredCommentUser + ? String.Empty + : GetGuestEditTokenHash(false); + + if (!IsRegisteredCommentUser + && (!AllowGuestComments || String.IsNullOrWhiteSpace(guestEditTokenHash))) { + errorMessage = "The guest correction window is not available for this submission."; return false; } @@ -975,20 +1168,22 @@ SELECT CONVERT(INT, SCOPE_IDENTITY());"; command.CommandText = @" SELECT TOP 1 CommentText, ParentCommentId, - CreatedOnDate + CreatedOnDate, + UserId, + DisplayName, + IsApproved, + GuestEditTokenHash FROM " + CommentsTable + @" WHERE CommentId = @CommentId AND PortalId = @PortalId AND TabId = @TabId AND ModuleId = @ModuleId - AND UserId = @UserId AND IsDeleted = 0;"; command.Parameters.Add("@CommentId", SqlDbType.Int).Value = commentId; command.Parameters.Add("@PortalId", SqlDbType.Int).Value = PortalId; command.Parameters.Add("@TabId", SqlDbType.Int).Value = TabId; command.Parameters.Add("@ModuleId", SqlDbType.Int).Value = ModuleId; - command.Parameters.Add("@UserId", SqlDbType.Int).Value = UserId; connection.Open(); @@ -1003,13 +1198,54 @@ WHERE CommentId = @CommentId Convert.ToDateTime(reader["CreatedOnDate"]), DateTimeKind.Utc); - if (DateTime.UtcNow > createdOnUtc.AddMinutes(RegisteredEditWindowMinutes)) + if (IsRegisteredCommentUser) { - errorMessage = "The 15-minute editing window for this comment has expired."; - return false; + if (reader["UserId"] == DBNull.Value + || Convert.ToInt32(reader["UserId"]) != UserId) + { + return false; + } + + if (DateTime.UtcNow > createdOnUtc.AddMinutes(RegisteredEditWindowMinutes)) + { + errorMessage = "The 15-minute editing window for this comment has expired."; + return false; + } + } + else + { + if (reader["UserId"] != DBNull.Value) + { + return false; + } + + if (Convert.ToBoolean(reader["IsApproved"])) + { + errorMessage = "This guest submission has already been approved and can no longer be corrected."; + return false; + } + + var storedGuestEditTokenHash = reader["GuestEditTokenHash"] == DBNull.Value + ? String.Empty + : Convert.ToString(reader["GuestEditTokenHash"]); + + if (String.IsNullOrWhiteSpace(storedGuestEditTokenHash) + || !SecureEquals(storedGuestEditTokenHash, guestEditTokenHash)) + { + return false; + } + + if (DateTime.UtcNow > createdOnUtc.AddMinutes(GuestEditWindowMinutes)) + { + errorMessage = "The 5-minute guest correction window for this submission has expired."; + return false; + } + + isGuestEdit = true; } commentText = Convert.ToString(reader["CommentText"]); + displayName = Convert.ToString(reader["DisplayName"]); isReply = reader["ParentCommentId"] != DBNull.Value; return true; } @@ -1084,6 +1320,98 @@ WHERE CommentId = @CommentId } } + private bool TryUpdateGuestComment( + int commentId, + string commentText, + bool languageFlagged, + out bool isReply, + out bool isApproved, + out string displayName, + out string guestEmail, + out string errorMessage) + { + isReply = false; + isApproved = false; + displayName = String.Empty; + guestEmail = String.Empty; + errorMessage = "The guest submission could not be updated. The 5-minute correction window may have expired or the comment may already have been approved."; + + if (IsRegisteredCommentUser || !PortalPostingEnabled || !AllowGuestComments || commentId <= 0) + { + return false; + } + + var guestEditTokenHash = GetGuestEditTokenHash(false); + + if (String.IsNullOrWhiteSpace(guestEditTokenHash)) + { + return false; + } + + string protectedGuestEmail = String.Empty; + + using (var connection = new SqlConnection(ConnectionString)) + using (var command = connection.CreateCommand()) + { + command.CommandText = @" +UPDATE " + CommentsTable + @" +SET CommentText = @CommentText, + IsApproved = 0, + IsLanguageFlagged = @IsLanguageFlagged, + EditedOnDate = GETUTCDATE(), + EditedByUserId = NULL, + LastModifiedOnDate = GETUTCDATE(), + LastModifiedByUserId = NULL +OUTPUT inserted.ParentCommentId, + inserted.IsApproved, + inserted.DisplayName, + inserted.GuestEmailEncrypted +WHERE CommentId = @CommentId + AND PortalId = @PortalId + AND TabId = @TabId + AND ModuleId = @ModuleId + AND UserId IS NULL + AND IsDeleted = 0 + AND IsApproved = 0 + AND GuestEditTokenHash = @GuestEditTokenHash + AND CreatedOnDate >= DATEADD(MINUTE, -@EditWindowMinutes, GETUTCDATE());"; + + command.Parameters.Add("@CommentText", SqlDbType.NVarChar, MaximumCommentLength).Value = commentText; + command.Parameters.Add("@IsLanguageFlagged", SqlDbType.Bit).Value = languageFlagged; + command.Parameters.Add("@CommentId", SqlDbType.Int).Value = commentId; + command.Parameters.Add("@PortalId", SqlDbType.Int).Value = PortalId; + command.Parameters.Add("@TabId", SqlDbType.Int).Value = TabId; + command.Parameters.Add("@ModuleId", SqlDbType.Int).Value = ModuleId; + command.Parameters.Add("@GuestEditTokenHash", SqlDbType.NVarChar, 64).Value = guestEditTokenHash; + command.Parameters.Add("@EditWindowMinutes", SqlDbType.Int).Value = GuestEditWindowMinutes; + + connection.Open(); + + using (var reader = command.ExecuteReader()) + { + if (!reader.Read()) + { + return false; + } + + isReply = reader["ParentCommentId"] != DBNull.Value; + isApproved = Convert.ToBoolean(reader["IsApproved"]); + displayName = Convert.ToString(reader["DisplayName"]); + protectedGuestEmail = reader["GuestEmailEncrypted"] == DBNull.Value + ? String.Empty + : Convert.ToString(reader["GuestEmailEncrypted"]); + } + } + + string unprotectedGuestEmail; + if (TryUnprotectGuestEmail(protectedGuestEmail, out unprotectedGuestEmail)) + { + guestEmail = unprotectedGuestEmail; + } + + return true; + } + private bool TryGetSelectedParentComment(out int? parentCommentId, out string displayName) { parentCommentId = null; @@ -1181,14 +1509,32 @@ WHERE CommentId = @CommentId } } - private void SetEditContext(int commentId, bool isReply, string commentText, bool replaceCommentText) + private void SetEditContext( + int commentId, + bool isReply, + bool isGuestEdit, + string displayName, + string commentText, + bool replaceCommentText) { ClearReplyContext(); hdnEditCommentId.Value = commentId.ToString(); pnlEditContext.Visible = true; - litEditContext.Text = isReply - ? "Editing your reply. Changes must be saved within 15 minutes of the original post." - : "Editing your comment. Changes must be saved within 15 minutes of the original post."; + + if (isGuestEdit) + { + litEditContext.Text = isReply + ? "Correcting your guest reply. Changes must be saved within 5 minutes of the original post and before moderator approval." + : "Correcting your guest comment. Changes must be saved within 5 minutes of the original post and before moderator approval."; + txtDisplayName.Text = displayName ?? String.Empty; + } + else + { + litEditContext.Text = isReply + ? "Editing your reply. Changes must be saved within 15 minutes of the original post." + : "Editing your comment. Changes must be saved within 15 minutes of the original post."; + } + litFormTitle.Text = isReply ? "Edit your reply" : "Edit your comment"; btnSubmit.Text = "Save changes"; @@ -1215,15 +1561,23 @@ WHERE CommentId = @CommentId { int editCommentId; - if (TryGetSelectedEditCommentId(out editCommentId) && IsRegisteredCommentUser) + if (TryGetSelectedEditCommentId(out editCommentId)) { string existingText; bool isReply; + bool isGuestEdit; + string displayName; string errorMessage; - if (TryLoadEditableComment(editCommentId, out existingText, out isReply, out errorMessage)) + if (TryLoadEditableComment( + editCommentId, + out existingText, + out isReply, + out isGuestEdit, + out displayName, + out errorMessage)) { - SetEditContext(editCommentId, isReply, existingText, false); + SetEditContext(editCommentId, isReply, isGuestEdit, displayName, existingText, false); return; } } @@ -1557,6 +1911,43 @@ WHERE PortalId = @PortalId return output.ToString().Trim(); } + private string GetGuestEditTokenHash(bool createIfMissing) + { + if (Session == null) + { + return String.Empty; + } + + var token = Convert.ToString(Session[GuestEditTokenSessionKey]); + + if (String.IsNullOrWhiteSpace(token) && createIfMissing) + { + token = GenerateSecurityToken(); + Session[GuestEditTokenSessionKey] = token; + } + + if (String.IsNullOrWhiteSpace(token)) + { + return String.Empty; + } + + var source = "JacarandaCommentsGuestEdit|" + + PortalId + "|" + TabId + "|" + ModuleId + "|" + token; + + using (var sha = SHA256.Create()) + { + var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(source)); + var output = new StringBuilder(hash.Length * 2); + + foreach (var value in hash) + { + output.Append(value.ToString("x2")); + } + + return output.ToString(); + } + } + private bool TryProtectGuestEmail(string email, out string protectedEmail) { protectedEmail = String.Empty; @@ -1591,6 +1982,40 @@ WHERE PortalId = @PortalId } } + private bool TryUnprotectGuestEmail(string protectedEmail, out string email) + { + email = String.Empty; + + if (String.IsNullOrWhiteSpace(protectedEmail)) + { + return false; + } + + try + { + var protectedBytes = Convert.FromBase64String(protectedEmail); + var emailBytes = MachineKey.Unprotect( + protectedBytes, + "JacarandaComments", + "GuestEmail", + PortalId.ToString(), + ModuleId.ToString()); + + if (emailBytes == null || emailBytes.Length == 0) + { + return false; + } + + email = Encoding.UTF8.GetString(emailBytes).Trim(); + return !String.IsNullOrWhiteSpace(email) && email.Length <= MaximumGuestEmailLength; + } + catch (Exception ex) + { + Exceptions.LogException(ex); + return false; + } + } + private string ComputeGuestRateLimitKey() { if (Request == null) @@ -1734,8 +2159,11 @@ WHERE PortalId = @PortalId return; } - var subjectPrefix = isEdit ? "Edited " : (isGuest ? "New guest " : "New "); - var subject = CleanEmailHeader(subjectPrefix + (isReply ? "reply" : "comment") + " on " + GetPortalName()); + var pageTitle = GetPageTitle(); + var subjectAction = !isApproved + ? "Comment awaiting approval" + : (isEdit ? "Comment edited" : "New comment"); + var subject = CleanEmailHeader(subjectAction + " — " + pageTitle); var body = BuildNotificationBody( commentId, isReply, @@ -1900,7 +2328,8 @@ WHERE PortalId = @PortalId + (isEdit ? " has been edited." : " has been submitted.")); body.AppendLine(); body.AppendLine("Portal: " + EncodeForNotification(GetPortalName())); - body.AppendLine("Page: " + EncodeForNotification(GetPageUrl())); + body.AppendLine("Page title: " + EncodeForNotification(GetPageTitle())); + body.AppendLine("Page link: " + EncodeForNotification(GetPageUrl())); body.AppendLine("Module: " + EncodeForNotification(ModuleConfiguration != null ? ModuleConfiguration.ModuleTitle : "Jacaranda Comments")); body.AppendLine("Comment ID: " + commentId); body.AppendLine("Author: " + EncodeForNotification(displayName)); @@ -1948,6 +2377,28 @@ WHERE PortalId = @PortalId return "DNN site"; } + private string GetPageTitle() + { + try + { + if (PortalSettings != null && PortalSettings.ActiveTab != null) + { + var tabName = (PortalSettings.ActiveTab.TabName ?? String.Empty).Trim(); + + if (!String.IsNullOrWhiteSpace(tabName)) + { + return tabName; + } + } + } + catch + { + // Fall through to a safe, non-empty subject value. + } + + return "DNN page"; + } + private string GetPageUrl() { try @@ -1980,7 +2431,7 @@ WHERE PortalId = @PortalId if (IsGuestPoster) { - moderationText = "Guest comments and replies are always held for approval. Your email address is private and is not displayed publicly. Guest submissions cannot be edited after posting; register or sign in first to receive a 15-minute edit window."; + moderationText = "Guest comments and replies are always held for approval. Your email address is private and is not displayed publicly. You may correct the text of your own guest submission for 5 minutes while it remains awaiting approval. Registered authors receive a 15-minute edit window."; } else { @@ -2010,6 +2461,7 @@ WHERE PortalId = @PortalId command.CommandText = @" UPDATE " + CommentsTable + @" SET IsApproved = @IsApproved, + GuestEditTokenHash = CASE WHEN @IsApproved = 1 THEN NULL ELSE GuestEditTokenHash END, LastModifiedOnDate = GETUTCDATE(), LastModifiedByUserId = @UserId WHERE CommentId = @CommentId @@ -2037,6 +2489,7 @@ WHERE CommentId = @CommentId command.CommandText = @" UPDATE " + CommentsTable + @" SET IsDeleted = 1, + GuestEditTokenHash = NULL, LastModifiedOnDate = GETUTCDATE(), LastModifiedByUserId = @UserId WHERE CommentId = @CommentId @@ -2055,6 +2508,184 @@ WHERE CommentId = @CommentId } } + private string GetPostingUnavailableMessage() + { + if (!PortalPostingEnabled) + { + return "New comments and replies are temporarily disabled across this site. Existing comments remain available to read."; + } + + if (!IsRegisteredCommentUser && !AllowGuestComments) + { + return "Please sign in to leave a comment or reply. Guest commenting is currently switched off."; + } + + return "Comment posting is currently unavailable."; + } + + private bool GetEffectivePortalBool(bool localValue, Func selector) + { + var settings = GetPortalCommentSettings(); + return settings.Available && settings.CentralSettingsActive ? selector(settings) : localValue; + } + + private string GetEffectivePortalString(string localValue, Func selector) + { + var settings = GetPortalCommentSettings(); + return settings.Available && settings.CentralSettingsActive ? (selector(settings) ?? String.Empty) : localValue; + } + + private int GetEffectivePortalInt( + int localValue, + Func selector, + int defaultValue, + int minimumValue, + int maximumValue) + { + var settings = GetPortalCommentSettings(); + var value = settings.Available && settings.CentralSettingsActive ? selector(settings) : localValue; + + if (value < minimumValue) value = minimumValue; + if (value > maximumValue) value = maximumValue; + return value; + } + + private PortalCommentSettings GetPortalCommentSettings() + { + if (_portalCommentSettingsLoaded) + { + return _portalCommentSettings; + } + + _portalCommentSettingsLoaded = true; + _portalCommentSettings = PortalCommentSettings.CreateDefaults(); + + try + { + using (var connection = new SqlConnection(ConnectionString)) + using (var command = connection.CreateCommand()) + { + command.CommandText = @" +SELECT PostingEnabled, + GuestPostingEnabled, + CentralSettingsActive, + DefaultAllowGuestComments, + DefaultRequireApprovalForNonEditors, + DefaultEnableLanguageFilter, + DefaultBlockedLanguageTerms, + DefaultMaximumCommentLength, + DefaultEnableRateLimiting, + DefaultRateLimitSeconds, + DefaultRateLimitMaxPosts, + DefaultRateLimitWindowMinutes, + DefaultEnableCaptcha, + DefaultEnableNotifications, + DefaultNotificationEmailAddresses, + DefaultIncludeCommentTextInNotifications +FROM " + PortalSettingsTable + @" +WHERE PortalId = @PortalId;"; + command.Parameters.Add("@PortalId", SqlDbType.Int).Value = PortalId; + connection.Open(); + _portalCommentSettings.Available = true; + + using (var reader = command.ExecuteReader(CommandBehavior.SingleRow)) + { + if (!reader.Read()) + { + return _portalCommentSettings; + } + + _portalCommentSettings.PostingEnabled = ReadPortalBool(reader, "PostingEnabled", true); + _portalCommentSettings.GuestPostingEnabled = ReadPortalBool(reader, "GuestPostingEnabled", true); + _portalCommentSettings.CentralSettingsActive = ReadPortalBool(reader, "CentralSettingsActive", false); + _portalCommentSettings.DefaultAllowGuestComments = ReadPortalBool(reader, "DefaultAllowGuestComments", false); + _portalCommentSettings.DefaultRequireApprovalForNonEditors = ReadPortalBool(reader, "DefaultRequireApprovalForNonEditors", true); + _portalCommentSettings.DefaultEnableLanguageFilter = ReadPortalBool(reader, "DefaultEnableLanguageFilter", false); + _portalCommentSettings.DefaultBlockedLanguageTerms = ReadPortalString(reader, "DefaultBlockedLanguageTerms", String.Empty); + _portalCommentSettings.DefaultMaximumCommentLength = ReadPortalInt(reader, "DefaultMaximumCommentLength", 4000); + _portalCommentSettings.DefaultEnableRateLimiting = ReadPortalBool(reader, "DefaultEnableRateLimiting", true); + _portalCommentSettings.DefaultRateLimitSeconds = ReadPortalInt(reader, "DefaultRateLimitSeconds", 60); + _portalCommentSettings.DefaultRateLimitMaxPosts = ReadPortalInt(reader, "DefaultRateLimitMaxPosts", 5); + _portalCommentSettings.DefaultRateLimitWindowMinutes = ReadPortalInt(reader, "DefaultRateLimitWindowMinutes", 15); + _portalCommentSettings.DefaultEnableCaptcha = ReadPortalBool(reader, "DefaultEnableCaptcha", false); + _portalCommentSettings.DefaultEnableNotifications = ReadPortalBool(reader, "DefaultEnableNotifications", false); + _portalCommentSettings.DefaultNotificationEmailAddresses = ReadPortalString(reader, "DefaultNotificationEmailAddresses", String.Empty); + _portalCommentSettings.DefaultIncludeCommentTextInNotifications = ReadPortalBool(reader, "DefaultIncludeCommentTextInNotifications", true); + } + } + } + catch + { + _portalCommentSettings = PortalCommentSettings.CreateDefaults(); + _portalCommentSettings.Available = false; + } + + return _portalCommentSettings; + } + + private static bool ReadPortalBool(IDataRecord record, string name, bool defaultValue) + { + var ordinal = record.GetOrdinal(name); + return record.IsDBNull(ordinal) ? defaultValue : Convert.ToBoolean(record.GetValue(ordinal)); + } + + private static int ReadPortalInt(IDataRecord record, string name, int defaultValue) + { + var ordinal = record.GetOrdinal(name); + return record.IsDBNull(ordinal) ? defaultValue : Convert.ToInt32(record.GetValue(ordinal)); + } + + private static string ReadPortalString(IDataRecord record, string name, string defaultValue) + { + var ordinal = record.GetOrdinal(name); + return record.IsDBNull(ordinal) ? defaultValue : Convert.ToString(record.GetValue(ordinal)); + } + + private sealed class PortalCommentSettings + { + public bool Available { get; set; } + public bool PostingEnabled { get; set; } + public bool GuestPostingEnabled { get; set; } + public bool CentralSettingsActive { get; set; } + public bool DefaultAllowGuestComments { get; set; } + public bool DefaultRequireApprovalForNonEditors { get; set; } + public bool DefaultEnableLanguageFilter { get; set; } + public string DefaultBlockedLanguageTerms { get; set; } + public int DefaultMaximumCommentLength { get; set; } + public bool DefaultEnableRateLimiting { get; set; } + public int DefaultRateLimitSeconds { get; set; } + public int DefaultRateLimitMaxPosts { get; set; } + public int DefaultRateLimitWindowMinutes { get; set; } + public bool DefaultEnableCaptcha { get; set; } + public bool DefaultEnableNotifications { get; set; } + public string DefaultNotificationEmailAddresses { get; set; } + public bool DefaultIncludeCommentTextInNotifications { get; set; } + + public static PortalCommentSettings CreateDefaults() + { + return new PortalCommentSettings + { + Available = false, + PostingEnabled = true, + GuestPostingEnabled = true, + CentralSettingsActive = false, + DefaultAllowGuestComments = false, + DefaultRequireApprovalForNonEditors = true, + DefaultEnableLanguageFilter = false, + DefaultBlockedLanguageTerms = String.Empty, + DefaultMaximumCommentLength = 4000, + DefaultEnableRateLimiting = true, + DefaultRateLimitSeconds = 60, + DefaultRateLimitMaxPosts = 5, + DefaultRateLimitWindowMinutes = 15, + DefaultEnableCaptcha = false, + DefaultEnableNotifications = false, + DefaultNotificationEmailAddresses = String.Empty, + DefaultIncludeCommentTextInNotifications = true + }; + } + } + private string GetModuleSettingString(string name, string defaultValue) { var key = SettingPrefix + name; @@ -2171,22 +2802,62 @@ WHERE CommentId = @CommentId return "Edited " + editedUtc.ToLocalTime().ToString("dd MMM yyyy, h:mm tt"); } - protected bool CanEditComment(object commentUserId, object createdOnDate) + protected bool CanEditComment( + object commentUserId, + object createdOnDate, + object isApproved, + object guestEditTokenHash) { - if (!IsRegisteredCommentUser || commentUserId == null || commentUserId == DBNull.Value - || createdOnDate == null || createdOnDate == DBNull.Value) + if (!PortalPostingEnabled || createdOnDate == null || createdOnDate == DBNull.Value) + { + return false; + } + + var createdUtc = DateTime.SpecifyKind(Convert.ToDateTime(createdOnDate), DateTimeKind.Utc); + + if (IsRegisteredCommentUser) + { + if (commentUserId == null || commentUserId == DBNull.Value) + { + return false; + } + + int ownerUserId; + if (!Int32.TryParse(Convert.ToString(commentUserId), out ownerUserId) || ownerUserId != UserId) + { + return false; + } + + return DateTime.UtcNow <= createdUtc.AddMinutes(RegisteredEditWindowMinutes); + } + + if (!AllowGuestComments || (commentUserId != null && commentUserId != DBNull.Value)) { return false; } - int ownerUserId; - if (!Int32.TryParse(Convert.ToString(commentUserId), out ownerUserId) || ownerUserId != UserId) + bool approved; + if (!Boolean.TryParse(Convert.ToString(isApproved), out approved) || approved) { return false; } - var createdUtc = DateTime.SpecifyKind(Convert.ToDateTime(createdOnDate), DateTimeKind.Utc); - return DateTime.UtcNow <= createdUtc.AddMinutes(RegisteredEditWindowMinutes); + var currentGuestEditTokenHash = GetGuestEditTokenHash(false); + var storedGuestEditTokenHash = guestEditTokenHash == null || guestEditTokenHash == DBNull.Value + ? String.Empty + : Convert.ToString(guestEditTokenHash); + + return !String.IsNullOrWhiteSpace(currentGuestEditTokenHash) + && !String.IsNullOrWhiteSpace(storedGuestEditTokenHash) + && SecureEquals(currentGuestEditTokenHash, storedGuestEditTokenHash) + && DateTime.UtcNow <= createdUtc.AddMinutes(GuestEditWindowMinutes); + } + + protected string EditCommentToolTip(object commentUserId) + { + return commentUserId == null || commentUserId == DBNull.Value + ? "Correct your guest submission within 5 minutes while it is awaiting approval" + : "Edit your own comment within 15 minutes of posting"; } private static string Truncate(string value, int maxLength) @@ -2444,13 +3115,27 @@ WHERE CommentId = @CommentId AND TabId = @TabId AND ModuleId = @ModuleId AND IsDeleted = 0 - AND (IsApproved = 1 OR (@CurrentUserId > -1 AND UserId = @CurrentUserId));"; + AND ( + IsApproved = 1 + OR (@CurrentUserId > -1 AND UserId = @CurrentUserId) + OR ( + @CurrentUserId < 0 + AND UserId IS NULL + AND IsApproved = 0 + AND @GuestEditTokenHash <> N'' + AND GuestEditTokenHash = @GuestEditTokenHash + AND CreatedOnDate >= DATEADD(MINUTE, -@GuestEditWindowMinutes, GETUTCDATE()) + ) + );"; command.Parameters.Add("@CommentId", SqlDbType.Int).Value = commentId; command.Parameters.Add("@PortalId", SqlDbType.Int).Value = PortalId; command.Parameters.Add("@TabId", SqlDbType.Int).Value = TabId; command.Parameters.Add("@ModuleId", SqlDbType.Int).Value = ModuleId; command.Parameters.Add("@CurrentUserId", SqlDbType.Int).Value = UserId; + command.Parameters.Add("@GuestEditTokenHash", SqlDbType.NVarChar, 64).Value = + IsRegisteredCommentUser ? String.Empty : GetGuestEditTokenHash(false); + command.Parameters.Add("@GuestEditWindowMinutes", SqlDbType.Int).Value = GuestEditWindowMinutes; connection.Open(); return Convert.ToInt32(command.ExecuteScalar()) > 0; @@ -2952,8 +3637,15 @@ WHERE CommentId = @CommentId
-

Comments

- +
+

Comments

+ +
+
+ Visible='<%# CanPostComments && (Convert.ToBoolean(Eval("IsApproved")) || CanModerateComments()) %>'> Reply @@ -3023,8 +3715,8 @@ WHERE CommentId = @CommentId CommandName="EditComment" CommandArgument='<%# Eval("CommentId") %>' CausesValidation="false" - ToolTip="Edit your own comment within 15 minutes of posting" - Visible='<%# CanEditComment(Eval("UserId"), Eval("CreatedOnDate")) %>'> + ToolTip='<%# EditCommentToolTip(Eval("UserId")) %>' + Visible='<%# CanEditComment(Eval("UserId"), Eval("CreatedOnDate"), Eval("IsApproved"), Eval("GuestEditTokenHash")) %>'> Edit @@ -3053,7 +3745,7 @@ WHERE CommentId = @CommentId - Please sign in to leave a comment or reply. Guest commenting is currently switched off for this module. + @@ -3101,7 +3793,7 @@ WHERE CommentId = @CommentId - Guest posting: every submission is reviewed before publication and cannot be edited after it is sent. Register or sign in before posting to receive a 15-minute editing window. + Guest posting: every submission is reviewed before publication. You may correct the text for up to 5 minutes while it remains awaiting approval. Register or sign in before posting to receive a 15-minute editing window.