Prying Open the Vanilla Forum Control Flow to Create Discussions in Embedded Forms

null

We’re using a Vanilla Forum for the Zettelkasten Method and ME Improved. It’s a PHP-based forum, and I like how modular it’s built. Writing plugins and themes isn’t a pain, and that’s something in my book already. It also allows us to power the comments with the forum. When you are the first to write a comment, the built-in “System” user creates a discussion with the blog post’s title and a short excerpt and your comment is added.

I wondered how this works. It wasn’t obvious, so I had a look at some of the more important directories of a Vanilla Forum installation.

  • applications/ hosts the main parts of the forum:
    • conversations/, the private messaging service,
    • dashboard/, the admin settings or backend, and
    • vanilla/, the forum part consisting of discussions and comments.
  • js/ has JavaScript both for rendering the backend and frontend.
  • library/ is very hard to understand at first. It contains a lot of PHP source code, and things like FileUtils.php or the database/ sub-directory immediately make sense. So it has shared utility stuff. But then there’s Addon, which is the base class for plugins, and I wonder why it is located in this directory. Isn’t this part of the admin dashboard? If not, what part of the software is loading the applications, and how much does it contain? What’s “core” about the core/ sub-directory and its class.bbcode.php file? All this beats me so far.
  • locales/ contains string translations.
  • plugins/ is where you download and extract 3rd party plugins into. (Why isn’t this called addons/?)
  • themes/ is obvious.
  • uploads/ contains images and other file attachments from forum posts.

In the backend, the functionality of embedding per-page comments on your website is aptly called “Embedding”. Searching for the phrase “embed” will produce lots of in-post embed functionality, like how external images, YouTube videos, Instagram posts, and Twitter cards are created from pasted URLs. The term is overloaded, so to speak.

Searching for the text that you see on the embedding settings page produces the path applications/dashboard/views/embed/universal.php. So, yeah, it’s part of the Dashboard aka settings application. There also is a controller counterpart in applications/dashboard/controllers/class.embedcontroller.php. It does a bit of configuration of the settings page but doesn’t actually handle comment posting.

While this was a dead-end, this does make sense so far. My web service programming experience is mostly limited to writing Ruby on Rails apps. Controllers respond to HTTP requests, and views are templates rendered by controllers. So when I searched for the contents of the backend, I ended up in a place where the backend’s HTTP GET request for the settings page is handled. Embedding the forum is not a plugin, but core functionality, so of course it’s all over the place.

You know, that’s what I like about most plugins: they are confined to their plugin folder and have to create a directory hierarchy inside it. It’s like one approach to modular source code organization. Think about an iOS app. You could create a directory with all NSViewController subclasses in it and call it “controllers”, and then have another directory with all the Storyboard files, and then another directory with all the model code. This eventually breaks down when the complexity of your application grows. Then you will find that organizing by modules is beneficial: you can have a profile/ directory with a Storyboard for user profile customizations, a controller that populates the view, and definitions of transaction models that represent the changes (or change network requests). Instead of a MVC app with directories for M, V, and C, you have many module directories with MVC sub-directories inside where applicable. That’s the kind of coding style plugins are confined to as well.

I would’ve preferred if embedding the forum was a plugin for this reason alone, honestly. I already had to look in the applications/dashboard/controllers/ and applications/dashboard/views/ directories and locate the files with “embed” in the file names. Would be easier if the structure had been applications/dashboard/embed/ with controller and view inside.

I digress.

So the dashboard directory was a dead-end. The dashboard does print setup instructions, though, and part of that shows the absolute URL of a JavaScript file you need to include in your website, like example.com/forum/js/embed.js. The js/embed.js file, currently at line 238, build the comment form’s target URL. The HTTP endpoint is example.com/discussions/embed, plus a lot of query parameters. One is misleading: vanilla_discussion_id is not targeting an existing discussion but will be null for the first comment; it’s a variable you can set to overwrite the system’s default behavior. By default, it uses an internal identifier to know which page you want to comment on. I just use the permalink of blog posts for this, but if you want to share comments across different pages, this would be how you do that.

Now the /discussions/embed endpoint must be somewhere inside the applications/vanilla/ directory since it pertains forum discussions directly. Inside applications/vanilla/controllers/ you will see both class.discussioncontroller.php and class.discussionscontroller.php (note the plural-S in the latter). The pluralized one should be the one we’re looking for, but it’s not particularly interesting when you look at the source, neither is its parent, VanillaController,. That one’s parent, Gdn_Controller,, that has a lot of stuff going on, though! I cannot see that a resource, like a discussion object, is created in any of these, though. Maybe that’s because I only know the Ruby on Rails convention of naming methods to correspond to CRUD (create, read, update, delete) requests and am unfamiliar with what the PHP community got used to in the meantime.

One thing I did find interesting was how DiscussionsController starts with this:

/** @var arrayModels to include. */
public $Uses = ['Database', 'DiscussionModel', 'Form'];

So a controller can specify the model types it uses. Database and Form sound like helpers, but DiscussionModel should be used to create a new discussion, right? Maybe a model is created and then passed to the Database for persistence somehow; or the model is using the Active Record pattern. I don’t know, yet.

While all are entertaining to read, let me spoil the ending: none were relevant for the purpose.

As I said in the very beginning, a user called “System” creates discussions for embedded comments. So I did a full-text search for “System”, subsequently limited it to applications/vanilla/ to skip all the backend stuff, and ended up at PostController where a $EmbedUserID variable was being set. The variable name sounds promising – and a few lines before that,, there are instructions about creating a discussion if there is none. Bingo!

There, the discussion content is created, with the excerpt being the page description by default. Nice. The actual implementation to get the description is in fetchPageInfo in library/core/functions.general.php on line 1000. The software actually tries to get Open Graph metadata and produces the description based on the Open Graph og:description, or plain HTML metadata. If all that fails, it falls back to get the page content and trims it down to 400 characters max.

Why looking for all this information in the first place?

Because I want to contribute to the FeedDiscussion plugin to prevent the RSS feed discussion poster from printing the whole content. The core embedding functionality limits the text, so why not the RSS cross-posting plugin?

Sadly, even though this interesting journey did produce the algorithm to limit the page content, it’s not reusable.

Here it is:

// THIRD PASS: Look in the page contents
if ($pageInfo['Description'] == '') {
    foreach ($dom->query('p') as $element) {
        trace('Looking at p for description.');

        if (strlen($element->plaintext) > 150) {
            $pageInfo['Description'] = $element->text();
            break;
        }
    }
    if (strlen($pageInfo['Description']) > 400) {
        $pageInfo['Description'] = sliceParagraph($pageInfo['Description'], 400);
    }
}

Ha, at least not all of it!

I can copy and paste this part into the RSS parser, and then rely on another core function, sliceParagraph, to perform the actual slicing. I’ll report back when I figured out if that works out of the box from a plugin.