My WordPress Paywall Adventure

/ 179

If you're running a highly-cached, high-traffic WordPress site and need a paywall, things can get a little weird. I try and make things a little less weird, but it's possible I made them even weirder.


The Idea

One of my clients runs a fairly popular WordPress website that over the last few years I have been doing a lot of work on to optimize performance and bring down hosting fees. In 2021, the client asked about implementing a paywall (I know, I know) and my first thought was, "Oh... great... all that work we did, and now we can't use micro-caching." But I told them, as always, I'd figure out the best option and get back to them.

After letting the idea marinate in my ADHD brain for a while, I came up with a fairly elegant and custom solution that they would not need to pay or rely on a third party for. The components were as follows:

  1. A decoupled user/subscription subsystem (on a separate EC2 instance)
  2. LUA NGiNX Configuration to check authorization outside of WordPress/PHP
  3. WordPress/PHP variable content rendering based on variables sent from LUA/NGiNX
  4. JavaScript module for allowing the user to log in or access their profile

User/Subscription Subsystem

To keep the amount of billing confusion down for the client, I opted to use AWS Cognito to handle authentication / users. Once the user successfully logs in, we send a request to a URL on the main server which contacts the user subsystem in the background (same AWS VPC) to verify that the user has, in fact, authenticated successfully.

It was a simple interface with basic operations for subscribing and unsubscribing.

I won't go too in depth, as this setup will work with any authentication system, but here's the pertinent NodeJS/express code for the API endpoint we hit from the main instance:

exports.check = function (req, res, next) {    let token = req.params.token    User.findOne({hashID: token}, function (err, user) {        if (err) return next(err)        return user    })        .then(user => {            const subscribed = user.stripe.plan == 'basic'            res.json({subscribed});        })        .catch(error => {            res.end()        });};

You'll see where we hit this endpoint in the next section.

NGiNX/LUA Configuration

lua_shared_dict sessions 15m;
lua_shared_dict sessions_locks 15m;

fastcgi_cache_path /etc/nginx/cache/mysite.com max_size=4096m levels=1:2 keys_zone=MYAPP:900m inactive=2d;

server {
    include /etc/nginx/sessions.conf;

    # define clean cache key
    set $c_uri "$scheme$request_method$host$request_uri";

    # set default access level
    set $pwaccess PUBLIC;

    # here we use LUA to ping the user endpoint and see if the user is subscribed
    # we do this to create a session on the main server instance / domain.
    # this is called from the user subsystem after the user logs in

    location /m {
       content_by_lua_block {
            local http = require "socket.http"
            local body, statusCode, headers, statusText = http.request('http://172.31.24.129/backend/check/' .. ngx.var.arg_id)
            local session = require "resty.session".start()

            if body == '{"subscribed":true}' then
                session.data.subscribed = "1"
                ngx.status = 200
            else
                session.data.subscribed = "0"
                ngx.status = 200
            end

            session:save()
            ngx.header['content-type'] = 'application/json'
            ngx.header['Access-Control-Allow-Origin'] = '*'
            ngx.say(body)
            ngx.exit(ngx.OK)
       }
    }

    # ------ 8< other config removed -------

    # check to see if we have a session, and if the user is marked as subscribed
    # and update the $c_uri NGiNX variable / our cache key accordingly
    rewrite_by_lua_block {
        local session = require "resty.session".open();
        if session.present and session.data.subscribed == "1" then
            ngx.var.c_uri = ngx.var.c_uri.."-FULL"
            ngx.var.pwaccess = "FULL"
        else
            ngx.var.pwaccess = "PUBLIC"
            ngx.var.c_uri = ngx.var.c_uri.."-PUBLIC"
        end
    }

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        # ------ 8< other config removed -------
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;

        # for debugging
        add_header PAGECACHEKEY $c_uri;

        # send our cache key and access level to PHP/WordPress,
        fastcgi_param CACHE_KEY $c_uri;
        fastcgi_param ACCESS $pwaccess;

        # set up the nginx caching
        fastcgi_cache MYAPP;
        fastcgi_cache_key "$c_uri";
        fastcgi_cache_background_update on;
        fastcgi_cache_lock on;
        fastcgi_cache_valid 200 301 302 15m;
        fastcgi_cache_use_stale updating error timeout invalid_header http_500;
    }

}

WordPress/PHP Variable Rendering

This is (basically) the code used to render the content based on our ACCESS level we retrieved in our LUA code with NGiNX before getting to the PHP side of things. Remember, our NGiNX cache key has the ACCESS level appended to the end, so all users of each individual access level will get the same cached render sent by NGiNX. There's no per-user data in this, it's just based on the access level.

<?php

// get the article content
$the_content = apply_filters('the_content', $the_content);

// check if the article is paywalled
$paywalled = get_field('paywalled');

// get our ACCESS level that we set in NGiNX
$access_level = $_SERVER['ACCESS'];

// if the article is paywalled and the user is not subscribed, or out of free views
$pwpub = $paywalled && ($access_level == 'PUBLIC' || $access_level == 'NONELEFT');
if ($pwpub):

    // cut the content after the second paragraph
    $the_content = implode("</p>", array_slice(explode("</p>", $the_content), 0, 2));
    
    // add your messaging here
    $the_content .= "<div id='paywall-notice' style='font-weight: 300;font-size: inherit;'>";
    if ($_SERVER['ACCESS'] == 'NONELEFT'):
        $the_content = "<div id='freeleft' style='opacity: 1 !important;filter:none !important;'>You have no free articles remaining his month. <a style='color:#3f75b5;text-decoration: underline' href='https://members.archpaper.com/signup' target='_blank'>Click here to subscribe</a>.</div>" . $the_content;
        $the_content .= '<strong>You have no more free articles remaining this month.</strong> ';
    endif;
    $the_content .= "To read the full article, you must be a subscriber <a style='color:#3f75b5;text-decoration: underline' href='https://members.archpaper.com/signup' target='_blank'>click here to subscribe</a> or <a class='login' style='color:#3f75b5;text-decoration: underline' href='https://members.archpaper.com/login'>sign in</a>.</div>";

// or, if the user is logged in but not subscribed, show a message that tells them how many free article views they have remaining, this is filled out by the javascript module hosted on the user subsystem, and modified in the DOM 
elseif ($_SERVER['ACCESS'] == 'FREE' && $paywalled):
    $the_content = "<div id='freeleft'> You have <span>...</span> free articles remaining this month. <a style='color:#3f75b5;text-decoration: underline' href='https://members.archpaper.com/signup' target='_blank'>Click here to subscribe</a>.</div>" . $the_content;
    
else:
    // do nothing, the user is subscribed! show them all the content!!1 (here for aesthetics)
endif;
?>

User Subsystem JavaScript Module

This module was loaded as a script on the main site, to enable per user functionality on the statically rendered/cached pages served from WordPress. This allows us to do things like show the user avatar, track articles they've read, or show them how many free articles they have remaining.

...code coming soon

This article is a work in progress, to be continued :)