Adding a ruby controller via plugin

Please bare with me here - first time developing a Discourse plugin. Was thrown into this project by my job…

Background:
This is a continuation of this post. For clarity, I’ll repeat the background here.

My company runs an internal Discourse Forum, as well as an internal WordPress site. The forums are configured to use SSO from the WordPress instance. On the forums, there is a category called “Updates”. On the WordPress side, when a user logs in there is the following PHP function:

$results = $this->_getRequest("c/updates.json", null, $username);

The $results are then parsed. For each topic, if the last_read_post_number < highest_post_number then we assume the user hasn’t fully read the topic and display an excerpt of the topic in a div on the WordPress site. In the div, there is an X. The intention is that clicking the X will set the user’s last_read_post_number equal to the highest_post_number so it is “read” in the forums too.

This was initially set up over a year ago by adding a custom controller and route to make the last_read_post_number. Problem is the route was set in a way that overwrote the Discourse routes.rb file, causing intermittent and eventually permanent problems. In the old topic I asked if there was a way to do this via the API, so I could integrate the code into WordPress. After chatting with engineering, it was determined that the PHP/JS code for the alerts were written by a previous contractor and were not going to be touched as they currently work.

As such, I’m now looking to recreate the old setup “properly”, which I’m assuming means a plugin. The old setup added two files via app.yml

run:
  - exec: echo "Beginning of custom commands"
  - replace:
      filename: "/etc/nginx/conf.d/discourse.conf"
      from: /client_max_body_size.+$/
      to: client_max_body_size 512m;
  - exec: cp /shared/routes.rb /var/www/discourse/config/routes.rb
  - exec: cp /shared/staff_corner_controller.rb /var/www/discourse/app/controllers/staff_corner_controller.rb

/shared/routes.rb
A 1±year-old copy of the Discourse routes.rb file with get "staffcorner/mark-read/:pulseUserId/:topicId" => "staffcorner#mark_read" added to the Discourse::Application.routes.draw do section.

/shared/staff_corner_controller.rb

class StaffcornerController < ApplicationController
        skip_before_filter :check_xhr, :redirect_to_login_if_required
        skip_before_filter :require_login
    def mark_read
        pulseUserId = params[:pulseUserId]
        topicId = params[:topicId]
        sso_record = SingleSignOnRecord.where(external_id: pulseUserId).first
        #wsUrl = SiteSetting.pulse_ws_url
        # No record yet, so create it
        if (!sso_record)
            # Load Pulse roles
            res = Net::HTTP.post_form(URI('http://webservices.idtech.com/StaffCorner.asmx/GetUserInfoByUserID'), {'Authenticate' => '{REDACTED}', 'companyID' => '1', 'userID' => pulseUserId})
            data = XmlSimple.xml_in(res.body)
            userData = data['diffgram'][0]['Result'][0]['User'][0]
            email = userData['Email'][0]
            username = email
            name = userData['FirstName'][0]
            user = User.where(:email => email).first
        end
        user = user || sso_record.user
        topic = Topic.find(topicId)
        if (!topic)
                render :json => {success: false, message: "Unknown topic"}
                return
        end
        topicUser = TopicUser.where(:user_id => user.id, :topic_id => topic.id).first
        if (!topicUser)
                topicUser = TopicUser.create(:user => user, :topic => topic, :last_read_post_number => topic.posts.last)
        end
        topicUser.last_read_post_number = topic.posts.last.id
        topicUser.save
        render :json => {success: true}
    end
end

I’ve tried implementing this as a plugin, but I’m not suceeding… Here’s what I’ve got at the moment.

Structure:

/staff-forum-plugin
..plugin.rb
../app
..../controllers
....../staff_corner_controller.rb
../config
....settings.yml
..../locales
......server.en.yml

plugin.rb:

# name: staff-forum-plugin
# about: Adds a route for staff corner to mark topics as read
# version: 0.1.0
# authors: Joshua Rosenfeld
# url: {Internal}
enabled_site_setting :mark_read_enabled
after_initialize do
    module ::StaffForumsMarkRead
        class Engine < ::Rails::Engine
            engine_name "staff_forums_mark_read"
            isolate_namespace StaffForumsMarkRead
        end
    end
    
    require_dependency 'application_controller'
    class StaffForumsMarkRead::MarkReadController < ::ApplicationController
        before_action :ensure_plugin_enabled
        def ensure_plugin_enabled
            raise Discourse::InvalidAccess.new('Staff Forums Mark Read plugin is not enabled') if !SiteSetting.mark_read_enabled
        end
    end
    
    Discourse::Application.routes.append do
        get "staffcorner/mark-read/:pulseUserId/:topicId" => "staffcorner#mark_read"
    end
end

staff_corner_controller.rb:
Same as above

settings.yml:

plugins:
  mark_read_enabled:
    default: true
    client: true

server.en.yml:

en:
    site_settings:
        mark_read_enabled: "Allow Staff Corner important updates banner to mark topics as read?"

Any thoughts, ideas, etc. would be much appreciated. This worked (at least it accomplished what it was supposed to do) when the files were copied as part the app.yml custom commands - but doing this broke the site, so I just need to figure out how to add the files the correct way.

2 Likes

Something about what’s not working would really help here; it looks like you’re on the right track.

I’d recommend you have a peek at some of the existing plugins which do this sort of thing; Retort is a pretty minimal example of adding a single route and controller to Discourse.

2 Likes

Oops, that would help, wouldn’t it! Completely forgot after writing all the details…

When testing this on our dev site, after clicking the X (which calls the URL) the following error is seen in the console:

XMLHttpRequest cannot load https://staffforum-dev.{redacted}.com/staffcorner/mark-read/637539/43. 
Redirect from 'https://staffforum-dev.{redacted}.com/staffcorner/mark-read/637539/43' to
'https://staffforum-dev.{redacted}.com/session/sso' has been blocked by CORS policy: 
No 'Access-Control-Allow-Origin' header is present on the requested resource. 
Origin 'https://staffdev.{redacted}.com' is therefore not allowed access.

Edit:

Yeah, I created this as is by reviewing @eviltrout’s plugin guides, and a number of existing plugins. Looking at Retort, I can’t seem to find a ruby controller file anywhere…

1 Like

Oh, you have a CORS issue, looks like between https://staffforum-dev.{redacted}.com and https://staffdev.{redacted}.com. (Moar on CORS)

If I had to guess, I’d say you need to modify your https://staffdev.{redacted}.com site to allow requests from https://staffforum-dev.{redacted}.com via the ACCESS-CONTROL-ALLOW-ORIGIN header. Other than that, your plugin is probably working mostly as you intend.

3 Likes

Thanks @gdpelican, I’m reading more on CORS now. Just to be sure I’m following you correctly - I need to modify staffdev (WordPress) to allow requests from staffforum-dev (Discourse)? Not the other way around?

Edit: Also, I’m having a hard time understanding why this worked previously (with the copied in route and controller) and doesn’t now if my plugin does in-fact replicate the old setup.

If I’m reading the error right, I think you’re doing an AJAX request FROM staffdev.{redacted}.com TO staffforumdev.{redacted}.com. Therefore you need to modify Discourse to allow requests from staffdev.

I believe that should be as simple as adding staffdev to the “allowed embed” list in the admin panel at /admin/customize/embedding

1 Like

Actually, the ‘embed’ section seems to be for embedding by iFrame.

The cors origins site setting might be more useful, and looks like you’ll need to “set the DISCOURSE_ENABLE_CORS env variable”

1 Like

I was just writing a post to say that didn’t work.

And now the testing stops for the weekend…I don’t have server access, so further testing will need to wait until I can talk to IT.

If you by chance have an answer to my question above I’d appreciate that.

My only guess would be that the previous implementation called the Discourse API from a php script, rather than from javascript in the browser. Therefore it wouldn’t be subject to CORS checks.

Nope - nothing has changed on the Wordpress side. Give me a few minutes and I can grab the implementation there. The only change is that instead of copying a modified routes file and controller via the run section of the app.yml, I’m now trying to do it as a plugin.

Edit: Here’s the relevant PHP file (that hasn’t changed):

<?php
/**
 * Staff Corner forum alert system.
 */
global $forumHelper;
global $forumUser;
global $sessionUser;
global $forumUsername;
$alerts = $forumHelper && $sessionUser ? $forumHelper->getUnreadAlerts($sessionUser->getUserId(), $forumUsername) : array();
if (count($alerts) > 0) :
    usort($alerts, function($a, $b) {
        return $a->bumped_at > $b->bumped_at ? -1 : $b->bumped_at < $a->bumped_at ? 1 : 0;
    });
?>
<script type="text/javascript">
    jQuery(function() {
        var $ = $ || jQuery;
        $('.alerts .dismiss a').click(function(e) {
            $(this).parents('.alerts li').remove();
            $.ajax($(this).attr('href'));
            e.preventDefault();
        });
    });
</script>
<div class="alerts full-width frame">
    <ul>
    <?php for ($i = 0, $len = count($alerts); $i < $len; $i++) :
        $alert = $alerts[$i];
        $date = new DateTime($alert->bumped_at);
        $now = new DateTime();
        $diff = $now->getTimestamp() - $date->getTimestamp();
        if ($diff < 60 * 60) { // minutes
            $hoursAgo = floor($diff / 60) . " minutes";
        } else if ($diff < 60 * 60 * 24) { // hours
            $hoursAgo = floor($diff / (60 * 60)) . " hours";
        } else { // days
            $hoursAgo = floor($diff / (60 * 60 * 24)) . " days";
        }
    ?>
        <li style="z-index: <?php echo $len - $i + 1 ?>">
            <h1><?php echo $alert->title; ?></h1>
            <p class="snippet"><?php echo $alert->msg; ?></p><a href="<?php echo $alert->url; ?>">See More</a>
            <p class="byline"><?php echo $hoursAgo ?> ago from <?php echo $alert->author ?></p>
            <p class="dismiss"><a href="<?php echo $alert->mark_read_url ?>">X</a></p>
        </li>
    <?php endfor; ?>
    </ul>
</div>
<?php endif; ?>
1 Like

Sorry, I’m probably making more confusion out of this than actually helping :wink:

This bit should mean that CORS is not an issue:

However, in your error it says

which suggests that a redirect is happening to the login page… That shouldn’t be happening, because the controller says skip_before_filter :check_xhr, :redirect_to_login_if_required.

As for why that’s the case, I don’t know from just reading the code, sorry! Hopefully someone more experienced will be able to spot something.

1 Like

Question: If the controller wasn’t routable, the skip_before_filter line wouldn’t be run. With the site being login_required, hitting any url should direct to login. Could that be happening here? Theoretically?

That sounds like a possibility

I’m not sure whether things in plugins/{name}/app/controllers are loaded automatically. You might need to “require” them, for example:

https://github.com/discourse/discourse-slack-official/blob/master/plugin.rb#L25

2 Likes

I think that did it :heart_exclamation: Doing some tests to double check…

1 Like

OK, testing complete. It seems to be working as expected @david, thanks!

There is still a console error, but it doesn’t seem to be preventing the plugin from doing what I want (making the banner go away in WordPress).

XMLHttpRequest cannot load https://staffforum-dev.{redacted}.com/staffcorner/mark-read/637539/47.
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Origin 'https://staffdev.idtech.com' is therefore not allowed access.