Skip to content
Merged
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
52 changes: 52 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,58 @@
- **Quality**: `composer lint` and `npm run lint:js`
- **Deploy**: `# TODO: add deploy command`

## Local WordPress browser testing

All plugins can use the shared existing local WordPress installation. Its root
is stored in the machine-local Git setting `sce.wordpressPath` (configured on
this development machine), not in public repository content. On another machine,
set that local Git setting to the existing WordPress root first. Test the actual
feature/PR worktree, not the canonical checkout or another branch. Build its
assets before opening the browser.

```sh
# Run from the linked worktree being tested.
WP_SITE_PATH="$(git config --get sce.wordpressPath)"
test -n "$WP_SITE_PATH" && test -f "$WP_SITE_PATH/wp-load.php" || exit 1
npm run build
# Inspect any existing destination first; never overwrite another session's link.
ls -ld "$WP_SITE_PATH/wp-content/plugins/simple-comment-editing"
ln -s "$PWD" "$WP_SITE_PATH/wp-content/plugins/simple-comment-editing"
wp --path="$WP_SITE_PATH" plugin activate simple-comment-editing
wp --path="$WP_SITE_PATH" option get siteurl
```

If the destination already points at this worktree, skip `ln -s`. If it points
elsewhere, coordinate before changing it; do not delete an existing directory.
For other plugins, substitute their plugin slug. Activation above is site-local;
do not network-activate or reset the shared installation without task-specific
authorization. Discover the current URL with WP-CLI rather than guessing it.

Use a real browser (`agent-browser` is available), with separate named sessions
for the guest owner, a logged-in subscriber, and a non-owner. Create a dedicated
test post with comments enabled. Verify submission, immediate Edit, Save, Cancel,
timer updates, and denied non-owner requests; inspect browser console/network
and confirm saved content with WP-CLI. Never publish browser cookies or passwords.

For dynamic-comment changes, test actual Ajaxify Comments and Lazy Load for
Comments separately. Ajaxify must be enabled in its settings after activation.
On Twenty Twenty-Five, set its comments-container selector to
`.wp-block-comments`: the default selector also matches the `#comments` heading,
causing a full-page fallback. Set a JavaScript page marker before submission and
confirm it survives, so a reload cannot be mistaken for working AJAX. Repeat
`SCE.init()` and the WP Ajaxify callbacks and check that requests/handlers do not
duplicate. Respect WordPress's comment-flood interval when switching browsers.

Keep the site and plugin symlink available for follow-up work. Restore temporary
integration settings/activation changes and remove only fixtures you created
when testing is complete; do not disturb other plugins, users, or content.

This browser site is **not** the PHPUnit database. `composer test` recreates
tables: use only a disposable `_test` database and environment credentials as
documented in `tests/README.md`, never the site's development database. Run
single-site and `WP_MULTISITE=1` sequentially, not against a shared database
concurrently. A green PHP suite does not replace real browser verification.

## Project Overview

<!-- Brief description of what this project does and why it exists. -->
Expand Down
106 changes: 99 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,103 @@ emitted as document events: `sceEditTextareaShow`, `sceCommentSavePre`,
`sceCommentQuicktagsInit`. The existing `sceCommentSavePost` event remains
available after a successful save.

## AJAX-submitted and lazy-loaded comments

SCE initializes on DOM readiness, including when its script executes after the
page has loaded. A DOM observer initializes newly inserted SCE controls. Custom
loaders can also call the public, root-scoped initializer **after** attaching
their markup:

```js
// insertedComment is the connected comment element, not an HTML string.
window.SCE.init( insertedComment );
// Omitting the root initializes the current document.
window.SCE.init();
```

Repeated calls reuse the same handlers and countdown, including while the time
request is pending. Replacing a comment cancels the old countdown and releases
its editor; stale responses cannot initialize the replacement. A failed time
request can be retried by calling the initializer again. Existing
`window.initEditCommentButton( button )`, `window.SCE_comments_updated( commentUrl )`,
and the `wpacAfterUpdateComments` document event remain available.

The initializer does not create edit markup or grant permission. The submitter
or lazy loader must render through WordPress's native `comment_text()` path and
return the current visitor's response, not shared cached author markup. Timer,
save, and delete requests still require the existing nonce and ownership checks.

### Guest ownership in an AJAX submitter

Ordinary WordPress submissions establish the editing cookie while the comment
is created. AJAX submitters using `admin-ajax.php` must explicitly opt in during
their **existing validated creation flow**. The following fragment belongs in
that handler, after its nonce, anti-spam, rate-limit, and submission checks and
before any response output:

```php
// Opt in only around this handler's real WordPress comment creation.
add_filter( 'sce_ajax_comment_submission', '__return_true' );
try {
$comment = wp_handle_comment_submission( wp_unslash( $_POST ) );
} finally {
remove_filter( 'sce_ajax_comment_submission', '__return_true' );
}
// Continue the submitter's existing WP_Error/success response handling.
// On success, render the new comment using comment_text(), not raw content.
```

This is not a new endpoint or a complete submission handler. The filter is
evaluated by SCE's `comment_post` callback for a newly created comment only.
Never call `comment_posted()` or `generate_cookie_data()` with a client-supplied
comment ID, and never bypass `sce_can_edit` or cookie verification. The original
response sets the guest's cookie and can render their permission-checked
controls immediately. Repeated creation callbacks preserve the original key.
The browser must accept that same-origin response's cookies; another browser
does not acquire ownership from knowing the comment ID or copying its markup.
Logged-in comment authors continue to use WordPress authentication.

### Compatibility and adapter boundaries

- **WP Ajaxify Comments:** the existing event and URL callback are retained and
use the repeat-safe initializer. Its normal WordPress submission route does
not require an arbitrary-comment cookie endpoint.
- **Lazy Load for Comments:** inserted markup is covered by the DOM observer;
there is no dependency on a private lazy-loader event. Rendering must still
use `comment_text()` and send the commenter's cookies. This describes the
integration contract, not an end-to-end verification claim for every version.
- **BuddyBoss:** premium-theme integration is **unverified**. In its documented
post-insertion callback, call `window.SCE.init( insertedComment )` only after
its comment element is connected. Its server adapter must follow the
validated submission/ownership route above and render native comment text.
The initializer alone cannot add SCE to a separate comment renderer.

Browser-verified on WordPress 7.1.3 multisite, PHP 8.4.25, Twenty Twenty-Five
1.4, and Chromium 147: Ajaxify Comments 3.2.2 supports guest/subscriber submission
and immediate saving without a page reload; Lazy Load for Comments 2.1.0
supports guest-owner controls and saving after REST-loaded insertion. Cancel
restores the saved text. Separate non-owner browsers receive no controls and
cannot save or delete another guest's comment. Replacing controls and repeating
the initializer/callbacks produces one time request and one countdown.
For Twenty Twenty-Five, configure Ajaxify's comments-container selector as
`.wp-block-comments`; its default also matches the `#comments` heading and
falls back to reloading. These checks do not verify BuddyBoss or a custom
`admin-ajax.php` submission adapter.

For release verification, test the actual AJAX and lazy-loading plugins with
separate commenter and non-owner browsers. Confirm one time request/countdown,
one save per click, successful immediate editing, denied non-owner requests,
and no browser-console or WordPress-log errors. Source-compatible callbacks
alone are not evidence of a verified plugin-version combination.

## Optional frontend asset loading

By default, SCE keeps its existing asset-loading behavior on singular content.
SCE preloads assets on singular views with open comments or existing comments,
so a first-time guest can use controls inserted after page load. Logged-in
visitors, editing cookies, and WP Ajaxify remain loading signals. Archives and
unrelated guest views with neither comments nor a form do not load these assets.
In **Settings → Simple Comment Editing**, two opt-in policies can reduce that
surface without changing the default:
surface; both policies remain disabled by default:

- **Load frontend assets on WordPress posts only** loads SCE on the built-in
`post` type only. It excludes ordinary pages, WooCommerce products, and all
Expand All @@ -115,8 +207,8 @@ or return `false` to suppress both `dist/sce-editing.js` and
## Troubleshooting cache, proxy, migration, and inline controls

SCE loads its frontend assets on singular content when the `sce_load_scripts`
filter allows them. It initializes the controls that
are present when the page `load` event fires. A cache, optimization plugin,
filter allows them. It initializes existing controls on DOM readiness and newly
inserted controls through its observer or `window.SCE.init( root )`. A cache, optimization plugin,
proxy, migration, or dynamically replaced comment list can therefore make an
otherwise valid installation appear broken. These checks isolate the observed
failure; they do not establish a historical support report's root cause.
Expand Down Expand Up @@ -154,9 +246,9 @@ administrator, and remove the test comment afterwards.
public support thread.

If another plugin replaces or lazy-loads comments after the original page load,
use its documented integration point to call the existing
`window.initEditCommentButton( button )` for each new `.sce-edit-button`. The
built-in Ajaxify Comments callback already performs that reinitialization.
the observer initializes inserted SCE controls. Its documented integration point
can also call `window.SCE.init( insertedComment )` explicitly; repeated calls are
safe. See the AJAX-submission ownership requirements above when markup is absent.

### Cache and optimization isolation

Expand Down
2 changes: 1 addition & 1 deletion dist/sce-editing.js

Large diffs are not rendered by default.

15 changes: 7 additions & 8 deletions includes/Functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,12 @@ public static function get_comment_content( $comment ) {
* @return bool true if can edit, false if not
*/
public static function can_edit( $comment_id, $post_id ) {
global $comment, $post;
global $post;
// Resolve the requested comment without changing the surrounding loop's global.
$comment = get_comment( $comment_id, OBJECT );
if ( ! $comment || (int) $comment->comment_post_ID !== (int) $post_id || ! in_array( (string) $comment->comment_approved, array( '0', '1' ), true ) ) {
return false;
}

/**
* Filter: sce_can_edit_pre
Expand All @@ -119,12 +124,6 @@ public static function can_edit( $comment_id, $post_id ) {
return apply_filters( 'sce_can_edit', true, $comment, $comment_id, $post_id );
}

if ( ! is_object( $comment ) ) {
$comment = get_comment( $comment_id, OBJECT ); // phpcs:ignore.
}
if ( (int) $comment->comment_post_ID !== $post_id ) {
return false;
}
$user_id = absint( self::get_user_id() );

// if we are logged in and are the comment author, bypass cookie check.
Expand Down Expand Up @@ -163,7 +162,7 @@ public static function can_edit( $comment_id, $post_id ) {

$cookie_value = self::get_cookie_value( 'SimpleCommentEditing' . $comment_id . $cookie_hash );
$comment_meta_hash = get_comment_meta( $comment_id, '_sce', true );
if ( $cookie_value !== $comment_meta_hash ) {
if ( ! is_string( $cookie_value ) || '' === $cookie_value || ! is_string( $comment_meta_hash ) || '' === $comment_meta_hash || ! hash_equals( $comment_meta_hash, $cookie_value ) ) {
return false;
}
}
Expand Down
10 changes: 10 additions & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ Yes. Enable Quicktags Toolbar in Settings -> Simple Comment Editing to add WordP
= Can I add custom fields or use a custom comment loop? =
Yes. The integration guide in the GitHub README documents comment-scoped extra fields, safe metadata saving, custom loops that call WordPress's native `comment_text()` path, button HTML, and inline action-row styling. Keep SCE's editor wrappers and controls intact so its nonce, ownership checks, Save, Cancel, and Delete actions continue to work.

= Can SCE initialize AJAX-submitted or lazy-loaded comments? =
Yes. SCE observes newly inserted controls, and integrations can call `window.SCE.init( insertedComment )` after attaching their markup. Repeated calls reuse handlers and the countdown. The existing WP Ajaxify event and `SCE_comments_updated` callback remain supported. Assets preload on singular content with open or existing comments, including for first-time guests; the asset-loading policies and `sce_load_scripts` override still apply.

Custom AJAX submitters must render through WordPress's native `comment_text()` path and opt into `sce_ajax_comment_submission` only around their existing validated comment-creation call, before sending output. The README has the adapter fragment. This establishes the guest cookie in the original submission response, not through a public comment-ID claim endpoint. Initialization never bypasses nonce, ownership, or expiry checks.

Lazy Load for Comments uses the DOM-insertion contract rather than a private plugin event; this is not a claim that every plugin-version combination has been verified. The BuddyBoss adapter is explicitly unverified and requires both native comment rendering and the validated server submission route. Test actual integrations with separate owner/non-owner browsers before release.

The GitHub README records browser verification with Ajaxify Comments 3.2.2 and Lazy Load for Comments 2.1.0 on Twenty Twenty-Five, including the Ajaxify comments-container selector required by that theme.

= Why doesn't this plugin come with any styles? =
It's impossible to style an inline comment editor for every theme. We've included basic HTML markup that is easily stylable to fit your theme.

Expand Down Expand Up @@ -104,6 +113,7 @@ You can report security bugs through the Patchstack Vulnerability Disclosure Pro
== Changelog ==

= Unreleased =
* New: Add a repeat-safe, root-scoped initializer for AJAX/lazy-loaded comment controls and an opt-in guest ownership hook for validated AJAX submissions.
* New: Add an optional native Quicktags toolbar and lifecycle hooks for external comment editors.
* Docs: Add supported custom-field, custom-loop, button HTML, and inline action-row integration examples.
* Fix: Keep inline comment editing available after failed AJAX requests, prevent duplicate submissions, and use accessible inline edit controls instead of links to the AJAX endpoint.
Expand Down
44 changes: 29 additions & 15 deletions simple-comment-editing.php
Original file line number Diff line number Diff line change
Expand Up @@ -740,22 +740,24 @@ public function add_scripts() {
* @param int $comment_id The Comment ID.
*/
public function comment_posted( $comment_id ) {
$comment = get_comment( $comment_id, OBJECT );
$post_id = $comment->comment_post_ID;
$post = get_post( $post_id, OBJECT );
$comment_status = $comment->comment_approved;

// Do some initial checks to weed out those who shouldn't be able to have editable comments.
if ( 'spam' === $comment_status ) {
return; // Marked as spam - no editing allowed.
$comment = get_comment( $comment_id, OBJECT );
if ( ! $comment || ! in_array( (string) $comment->comment_approved, array( '0', '1' ), true ) ) {
return;
}
$post_id = $comment->comment_post_ID;
$post = get_post( $post_id, OBJECT );
if ( ! $post || get_comment_meta( $comment_id, '_sce', true ) ) {
return; // Never replace an existing ownership key on repeated hooks.
}

// Remove expired comments.
$this->remove_security_keys();

$user_id = Functions::get_user_id();
if ( (int) $comment->user_id !== $user_id ) {
return; // Creating a comment on behalf of another user grants no ownership.
}

// Don't set a cookie if a comment is posted via Ajax.
/**
* Filter: sce_can_edit_cookie_bypass
* Bypass the cookie based user verification.
Expand All @@ -775,10 +777,18 @@ public function comment_posted( $comment_id ) {
$cookie_bypass = true;
update_comment_meta( $comment_id, '_sce', 'post_author' );
}
if ( ! defined( 'DOING_AJAX' ) && ! defined( 'EPOCH_API' ) ) {
if ( false === $cookie_bypass ) {
$this->generate_cookie_data( $post_id, $comment_id, 'setcookie' );
}
/**
* Opt a validated AJAX submitter into ownership during comment_post only.
*
* The integration must validate and create the comment in this request,
* before output. This is not a permission or arbitrary-comment claim API.
*
* @param bool $supported Whether this is a supported submission flow.
* @param \WP_Comment $comment The newly created comment.
*/
$ajax_submission = wp_doing_ajax() && apply_filters( 'sce_ajax_comment_submission', false, $comment );
if ( false === $cookie_bypass && ( ! wp_doing_ajax() || $ajax_submission ) && ! defined( 'EPOCH_API' ) && ! headers_sent() ) {
$this->generate_cookie_data( $post_id, $comment_id, 'setcookie' );
}
} //end comment_posted

Expand Down Expand Up @@ -889,7 +899,10 @@ public function generate_cookie_data( $post_id = 0, $comment_id = 0, $return_act

// Now store a cookie.
if ( 'setcookie' === $return_action ) {
setcookie( $cookie_name, $cookie_value, $cookie_expire, COOKIEPATH, COOKIE_DOMAIN );
if ( setcookie( $cookie_name, $cookie_value, $cookie_expire, COOKIEPATH, COOKIE_DOMAIN ) ) {
// Let the original submission response render permission-checked UI.
$_COOKIE[ $cookie_name ] = $cookie_value;
}
} elseif ( 'removecookie' === $return_action ) {
setcookie( $cookie_name, $cookie_value, time() - 60, COOKIEPATH, COOKIE_DOMAIN );
}
Expand Down Expand Up @@ -923,7 +936,8 @@ public function generate_cookie_data( $post_id = 0, $comment_id = 0, $return_act
* @return bool True to load scripts, false if not
*/
public function maybe_load_scripts( $yes ) {
if ( defined( 'WPAC_PLUGIN_NAME' ) || is_user_logged_in() ) {
// Preload on comment-bearing singular views, before any AJAX/lazy insertion.
if ( defined( 'WPAC_PLUGIN_NAME' ) || is_user_logged_in() || comments_open() || get_comments_number() > 0 ) {
return true;
}

Expand Down
Loading
Loading