WordPress comments package

Heyy, I was all this week investigating how to make comments work on wordpress.com, mainly focused on Akismet and nonces, and I discovered that nonces can be ignored after all.

I did some tests and I was able to send a comment using Postman to a sample site I have in wordpress.com, just sendind this as x-www-from-urlencoded

comment:Hi all!
author:David
email:david@somesite.test
url:https://somesite.test
comment_post_ID:34
comment_parent:0

and setting this headers:

user-agent: Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1

The only thing causing trouble is CORS, as request from a Frontity site to a wordpress.com one are made from a different origin, and browers doesn’t allow that. I guess that could be fixed creating a server extension that would act as a proxy, sending the comments from the Node.js server instead of doing that from the browser.

If you are courious, this is the workflow Akismet does for comments:

Sketch: https://excalidraw.com/#json=5675757456064512,zd4Srp3mMDqM1_6a4pMMBg

I did several tests with Akismet in a local WordPress instance and comments where published event without nonce or an invalid nonce, also checking that the nonce verification was failing, so that part doesn’t seem to affect.

1 Like

Does it send something indicating that it had a proper nonce to the Akismet server or the request to the Akismet server is equal in both cases?

The result of evaluating the nonce is sent along with the comment to the Akismet server, so it will receive 'failed' or 'passed' for the $comment['nonce'], if this is what you are asking.

Ok, so I guess that Akismet may be using that in its algorithm. For example:

  • Nonce: passed => chances to be spam, 40%.
  • Nonce: failed or inactive => chances to be spam, 70%.

Even though it is not required, right?

We I am trying to see if we can or cannot assume that the presence of the nonce is ignored by the Akismet server.

It may be, actually. But I don’t know. None of the commets I posted during the tests were marked as spam.

Regarding Akismet’s nonces, I guess a better way would be to disable them in the WordPress site, if possible. That way comments would be sent with $comment['nonce'] set as inactive which it seems to be a better value than failed (we don’t know how Akismet handles that value, though).

I said “if possible” because disabling nonces is something you can’t do on wordpress.com as it must be done programmatically, I mean, there is not an option in the GUI to do so.

For wordpress.com sites maybe the nonces can be extracted from the HTML code of the WordPress page, but for wordpress.org sites we have to take into consideration that the HTML code from WordPress cannot be readed in the case the theme bridge is used.

Also, a proxy would be needed anyway for posting comments form Frontity sites using a wordpress.com source as they would be in a different domain.

To summarize:

WP.com comments (with Akismet)

  • A proxy would be needed to post comments using /wp-comments-post.php
  • Nonces cannot be deactivated, but we can get them from the HTML or ignore them

WP.org comments (with Akismet)

  • No proxy needed (not 100% sure, it may depend on the hosting?)
  • Nonces can be deactivated programmatically
  • Nonces should not be obtained from the HTML (that won’t work for the PHP theme bridge)

@dev-team, before writing down the implementation proposal, could you take a look at this thread and give some feedback? Just to be sure I’m going to the right direction. :slightly_smiling_face:

I’ve read the whole thread and it sounds like yeah, you’re going in the right direction :slightly_smiling_face:

There are a couple of things that I miss in my mental model though:

  1. What do you mean by a “proxy server” in this context? I think that you mean extending the nodejs frontity server with an endpoint that you can call from the client. And then that endpoint will can call wp-comments-post.php on wp.com or wp.org. But I’m not 100% sure if that’s what you mean :slight_smile:
  2. I don’t really know why we need the proxy on wp.com but (probably) not on wp.org?
  3. Why should we not obtain the nonce from the HTML on wp.org and why does it not work with the Theme Bridge?

My mental model of the theme bridge was that it just passes on whatever HTML the frontity server has rendered. So I thought that the nonce should be included in that HTML that the frontity server passes to the theme bridge?

You can explain later or during the daily if that’s more convenient for you - it shouldn’t block you from continuing - it’s more for my own understanding and maybe just maybe stimulates some insight :sweat_smile:

Thanks for the research @david.

WP.com

I guess we could add that in the future once we add server extensibility, right? Something like:

export const server = ({ app }) => {
  app.use(get("wp-comments/post"), (ctx) => {
    // Do the call to /wp-comments-post.php here.
  });
};

Yeah, maybe this server function can do a fetch of a WP.com HTML to get one.

With those two things, I guess commenting on WP.com is possible. Maybe we also need to change the User Agent of the call to /wp-comments-post.php to simulate a browser call.


WP.org

It may depend on the hosting, yes. Maybe in the future we can always use the proxy, but for now if people have this problem they can fix it themselves.

We can create and send a comment nonce to the Frontity server on the request. Then, the wp-comments package can use it for posting.

$comment_nonce = wp_create_nonce("comment_nonce");
$html = wp_remote_get($frontity_server_url . "?_wp_nonce_comment=" . $comment_nonce);

Then, we eliminate all the _wp_nonce_xxx queries sent by the Theme Bridge with either Router Converters or filters (whatever comes first).


Ok, so I’d say, let’s do the first version, only for WP.org similar to what we had in the old version, and let’s move the proxy and Theme Bridge nonces to different FD to be addressed in the future, right?

Implementation proposal

At this moment we just want to implement the first two user stories, the one to fetch comments and get them from the state, and the other one to publish new comments:

As a Frontity user
I want to be able to access my WordPress comments in the state
so that I can consume them however I want

As a Frontity user
I want to be able to call an action to post a new comment
so that I can integrate it in my Comments forms

It is out of scope to export any React component from this package (like a comments list or publish form), although we need to write examples in any of our starter themes showing how to use the package.

actions.comments.create

Publish a new comment with the specified content.

Usage

actions.comments.create({
  comment: "This is awesome, thanks!",
  author: "David Arenas",
  email: "david@frontity.com",
  url = "https://frontity.org",
  postId: 60,
  parentId: 0,
});

Arguments

It receives the following arguments

Name Type Default Required Description
comment string - true The comment text.
author string - true Author name that will be shown with the comment.
email string - true The email address of the author.
url string “” false URL of the author’s website.
postId number - true ID of the post where this comment will be posted.
parentId number 0 false ID of the comment parent (if this comment is a reply).

Description

Under the hood, this action would send a POST request to the PHP endpoint used to create comments with data in form-urlencoded format.

To create the URL for that endpoint, the action should get state.source.api and replace /wp-json by /wp-comments-post.php.

Note that this action won’t be supported by wp.com sites. It would be a good idea to detect that case and show a warning message.

The implementation could be something like this:

// Generate form content.
const body = new URLSearchParams();
body.set("comment", comment);
body.set("author", author);
body.set("email", email);
body.set("url", url);
body.set("comment_post_ID", postId);
body.set("comment_parent", parentId);

// Generate endpoint URL.
const commentsPost = state.source.api.replace(/\/wp-json\/?$/, "/wp-comments-post.php")

// Send a POST request.
await fetch(commentsPost, {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" }
  body
});

libraries.source.handlers[commentHandler]

This handler should fetch all comments that belongs to a specific post.

It will work like any other handler so you can use actions.source.fetch to get comments from the REST API and add them to the Frontity state.

In this implementation, all the comments for that post will be fetched.
This means that, if there are more than 100 comments (that’s the maximun value for per_page), more requests should be done.
We can use total and totalPages response headers for that.

A possible implementation is already done in this PR: frontity/frontity#225

Example

Doing the following

actions.source.fetch("@comments/13")
const data = state.source.get("@comments/13");

you will get a data structure similar to this:

data = {
  post: 13,
  areComments: true,
  items: [
    {  type: "comment", id: 2 },
    {  type: "comment", id: 3 },
    {  type: "comment", id: 4, children: [
      { type: "comment", id: 5 },
      { type: "comment", id: 6 },
      { type: "comment", id: 7, children: [
        { type: "comment", id: 8 },
        { type: "comment", id: 9 },
      ] },
    ] },
    {  type: "comment", id: 10 },
    {  type: "comment", id: 11 },
  ]
}

Comments are populated in state.source.comment by id.
You can later iterate over data.items and get the comments from there.

data.items.map(({ id, children }) => {
  // You can iterate over children as well.
  if (children) ... ;
  // Return the comment stored in the state.
  return state.source.comment[id];
})

:warning: Important note

wp-source package uses schemas to know what kind of entities it receives from the REST API and where it should be stored.

Right now it doesn’t support comments and so we should either add a new schema for comments in that package or make schemas extensible so any package can add their own schemas (schemas are being deprecated in the next version of wp-source so it doesn’t make sense to make them extensible).

Possible issues

  1. Add action to create new comments. #439
  2. Add handler to fetch the state with the comments. #440
2 Likes

Let’s go with the hardcoded schema because I want to remove schemas in source v2, and it wouldn’t make much sense to add a system to make schemas extensible that we are going to deprecated.

@David do you want to discuss here how to manage errors?

In my opinion, they should be added to the state, in a map of post ids:

interface CommentForm {
  isSubmitting: boolean;
  isError: boolean;
  errorMessage: string;
}

interface WpComments extends Package {
  state: {
    comments: {
      commentForms: Record<number, CommentForm>;
    };
  };
}

I’m not 100% about the specific props, maybe it needs more, or less, or different. But we should try to stay as close to the other APIs as possible (like @frontity/wp-source).

We could also store the input fields in that map so they are preserved among route changes and can also be access by other packages/components.

interface CommentForm {
  isSubmitting: boolean;
  isError: boolean;
  errorMessage: string;
  inputs: {
    name: string;
    email: string;
    comment: string;
  };
}

I think validation is not needed, but if it were, it could also be added to the map.

I like it!

About the inputs object, if we add that to the state, would it have sense to implement actions.comments.create (or submit, if we change the name) with the same arguments?

Maybe it would be enough to pass just postId as argument and take the rest of the data from the state. What do you think? :thinking:

Uhmm… so we’d be forcing themes using the actions.comments.submit to populate the same state. It doesn’t sound bad, as other packages relying on that state being there will work with themes that implement the form manually. Yes, I like that.

In that case, I’d provide an additional action for the onChange, like:

export default {
  actions: {
    comments: {
      changeInput: ({ state }) => ({ postId, name, value }) => {
        // ...
      },
    },
  },
};

So they can use them like:

const Input = ({ actions, name, state, postId }) => (
  <input
    name={name}
    onChange={(e) => {
      actions.comments.changeInput({ name, postId, value: e.target.value });
    }}
    value={state.comments.forms[postId].inputs[name]}
  />
);

What do you think? Do they need anything else?

We should add another property to indicate that it was sucessfully sent, right? Something like isSubmitted or isSent

All these props need to make sense when you start writing a second/third comment.

Yup, I’ll think about that and write an extra implementation proposal for this part (or maybe update the last one).

Well, it took some time but here it is!

Implementation Proposal (extended)

:warning: Only for submitting comments.

Package

This would be the types related to the comments submition. I’ll explain the different actions and the state later, this is just to have an idea.

interface Form {
  isSubmitting: boolean;
  isSubmitted: boolean;
  isError: boolean;
  errorMessage: string;
  fields: Fields;
}

interface Fields {
  name: string;
  email: string;
  comment: string;
  url?: string;
  parent?: number;
}

interface Comments extends Package {
  state: {
    comments: {
      forms: Record<string, Form>;
    };
  };
  actions: {
    comments: {
      submit:
        | AsyncAction<Comments, number>
        | AsyncAction<Comments, number, Fields>;
      updateForm: Action<Comments, number, Partial<Fields>>;
    };
  };
}

State

state.comments.forms

A map of form statuses and fields per post ID. The properties indicating the current status for a form submition (i.e. after running actions.comments.submit) would be the following:

property type description
isSubmitting boolean true if the submit request has been sent but was not fullfiled yet.
isSubmitted boolean true if the submit request was sent and it has been accepted.
isError boolean true if the submit request was sent and it has been rejected.
errorMessage string Error description if the request has failed.

The fields property would contain an object with each field and its current value. They would be the same as what was specified before as the arguments of the create action.

Actions

submit(postId: number, fields?: Field) => Promise

Submits a comment to the specified post.

:warning: This replaces the previous create action.

Description

This action submits a comment on the specified post, using the fields inside state.comments.forms[postId].

If fields are passed as argument, those fields would override the content of state.comments.forms[postId].fields.

The action resets the form status for the specified post, sets isSubmitting to true and sends a request to WordPress to publish the comment. That request is sent to wp-comments-post.php. After that, the status is updated depending on the request result.

The possible HTTP responses are the following:

  • 200 OK, empty body - The request has failed because the post ID is invalid.
  • 200 OK, HTML body - The request has failed because name or email are missing or have an invalid format.
  • 302 Found - The comment was submitted.
  • 409 Conflict - The request has failed. The comment was already submitted, is duplicated.

Then, the form status is updated accordingly.

If parent is specified and it’s an invalid comment ID, the request won’t fail, it will be published in the root of the comments tree.

Examples

// Using the fields inside the form.
actions.comments.submit(post.id)

// Update the fields inside the form and send them. 
actions.comments.submit(post.id, {
  comment: "Hello world!",
  name: "John Doe",
  email: "johndoe@frontity.test",
});

updateForm(postId: number, fields: Partial<Field>) => Promise

Update fields in the specified form (by post ID).

It receives any number of fields in key-value format.

When this action is executed, all the status properties in the specified form are reset to false. This is because the status refers to a specific set of field values.

Examples

// Possible usage in an <Input> component.
const Input = ({ actions, name, state, postId }) => (
  <input
    name={name}
    onChange={(e) => {
      actions.comments.updateForm(postId, { [name]: e.target.value });
    }}
    value={state.comments.forms[postId].fields[name]}
  />
);
1 Like

Awesome :slightly_smiling_face:

By the way, what is the plan when a comment is awaiting moderation? Did you take it into account?

Yup, that’s a thing. For comments awaiting moderation, the only way would be to fetch again the comments and look there for the submitted one. If the comment is not fetched, we can assume that it isn’t approved yet.

Actually, I thought of fetching the comments for that post right after the submition process has ended, I forgot to add that to the proposal.