Wednesday, August 9, 2023

Subscriber Only: A Technical Post Mortem

Subscriber Only: A Technical Post Mortem

Around 3 months ago, I launched a service called Subscriber Only. Due to lack of users, I’m shutting it down. Such is life.

I’m making all of the code I wrote public. Get it while it’s fresh from GitHub.

https://github.com/subscriber-only/subscriber-only

Here’s a couple of interesting things I did and learned while working on it.

Overview

Subscriber Only (SO) allows writers to tag certain posts on their Jekyll blog as gated/premium/paid and then figures out if the reader should or should not have access to the said post. It handles all the machinery needed to maintain subscriptions. It’s kind of like Substack, Patreon or OnlyFans.

It poses a unique challenge, when compared to the mention sites, because the content is served from the writer’s own hosting on their own domain and not a centralized place (sort-of).

Here’s how I went about solving this.

First, there’s a Jekyll plugin that hooks into the site’s build step. Every post that’s tagged with subscriber_only gets its content stripped and uploaded to SO’s backend.

Second, there’s an “agent” script that gets added to the post’s <head> – kind of like Google Analytics. This script figures out if the reader is subscribed to the site they’re currently reading and shows them either the content or a paywall with instructions on how to login or subscribe.

This script is, probably, the most interesting part of the whole project. Check out so.js

The logic that’s associated with this script turned out to be much more complicated than I imagined. Starting with…

CORS

It turns out that if you want to issue a request from your-jekyll-site.com to app.subscriber-only.com that you can’t just do it. Even if the script that’s issuing the requests is at app.subscriber-only.com/so.js

SO must tell the browser which origins – domains – are allowed to issue requests to it and even which headers it accepts.

There’s a pretty nice gem called rack-cors that let’s you configure this in a declarative way. You can find SO’s configuration in the cors.rb initializer.

Poor man’s React

Another challenge was building the paywall that gets shown to the reader if they’re not subscribed.

Screenshot of Subscriber Only's paywall on a default Jekyll Site

Rendering the paywall needed to not require much code as it would bloat so.js which would delay the script’s download and make the user experience worse. This ruled out large UI libraries like React and EJS.

It also needed to be better than just concatenating strings together to form some HTML because I care about developer ergonomics.

I settled on a createElement() function that looks very much like React’s. I found a version of this function on Hacker News but can’t find the link. Here’s how it looks.

function createElement(type = "div", props = {}, children = []) {
  if (props?.constructor !== Object) {
    children = props;
    props = {};
  }
  if (!Array.isArray(children)) children = [children];
  const el = document.createElement(type);
  Object.assign(el, props);
  Object.assign(el.style, props.style || {});
  el.append(...children.filter(Boolean));
  return el;
}

It takes an element type, an object of props which must map to the properties the element has and an array of children. Then it returns the element which can be placed on the DOM.

Here’s the function in action. It’s being used to build some placeholder sentences that get displayed while the script is running background operations.

const e = createElement;

function PlaceholderSentence(width) {
  return e("span", { style: {
    display: "inline-block",
    minHeight: "1em",
    verticalAlign: "middle",
    cursor: "wait",
    backgroundColor: "currentColor",
    opacity: .5,
    width,
  }});
}

function PlaceholderRow(widths) {
  const style = { display: "flex", gap: "8px" };
  return e("div", { style }, widths.map(PlaceholderSentence));
}

function PlaceholderText() {
  const style = {
    display: "flex",
    flexDirection: "column",
    gap: "8px"
  };

  return e("div", { style }, [
    PlaceholderRow(["30%", "70%"]),
    PlaceholderRow(["10%", "50%", "40%"]),
    PlaceholderRow(["40%", "60%"]),
    PlaceholderRow(["20%", "40%", "40%"]),
    PlaceholderRow(["90%"]),
  ]);
}

I found this pretty simple and cool and plan on using it on future projects in similarly constrained environments.

Poor man’s OAuth2

Then there was the matter of authentication and authorization.

The reader needs to be able to go to your-jekyll-site.com and either be shown the post’s content or a paywall with a “Subscribe” link. After they follow the link and complete the subscription process, they must be redirected back to the post and, this time, be shown its content. Next time they access the post – or any other post from that site – they must already be logged in and immediately shown the content.

Initially, I had thought of a very simple system. After the reader would log into app.subscriber-only.com, the session cookie would be set. This cookie would then be available on writer’s sites because the script using this cookie would be served from the same origin at app.subscriber-only.com/so.js.

Turns out this would have worked several years ago but no longer does. A cookie from app.subscriber-only.com on your-jekyll-site.com is considered a “third-party cookie”. These cookies are being phased out by browsers and are no longer sent with requests because they were being used by ad companies to track users across sites. This is why we can’t have nice things.

I ended up implementing a flow similar to OAuth2’s Authorization Code Grant but with less ceremony. It goes like this.

First, the reader clicks the “Subscribe” button on the paywall and gets redirected to app.subscriber-only.com. They can then create their account, input their credit card details and complete the subscription flow. At the end of the flow, they’ll have a “Go back to the post” link.

This link will have a ?code=<randomly generated nonce> query parameter. The nonce is stored on the database and associated with the reader.

The so.js script reads the code from the query string and issues a request to app.subscriber-only.com/api/v1/access_tokens which includes the said code.

The code is verified to exist, deleted and an access token is returned in its stead. This access token is stored in a (first-party) cookie and passed in all further requests going out to app.subscriber-only.com.

Unless the reader decides to clean their browser’s cookies, they’ll be authenticated from now on. The disadvantage, when compared to the naive approach I initially started with that didn’t work, is that the reader won’t be automatically authenticated if they go to another-jekyll-site-using-so.com.

Phoenix’s Contexts in Rails

SO’s backend is written in Ruby on Rails.

The general Rails recommendation, when it comes to code organization, is to push all your business logic into the model and keep your controller as lean as possible. I agree with the latter part but not so much the first. You can end up with huge models, which complicates maintenance, and relying too much on Rails’ callbacks which make code flow harder to understand.

The usual solution to this, in the Rails world, is to use Service Objects. The usual implementation of the Service Object goes like this.

class ApplicationService
  def self.call(*args, &block)
    new(*args, &block).call
  end
end

class CreateUserService < ApplicationService
  def call(email)
    user = User.create!(email:)
    
    UserMailer.with(user:).welcome_email.deliver_later
    
    user
  end
end

# Usage
CreateUserService.call(params[:email])

This fits well into Ruby, it being an object-oriented language, but I find it inelegant and cumbersome as you end up with a ton of methods called call, classes named like methods and boilerplate inheritance.

The Phoenix framework – a.k.a. Elixir on Rails – has the concept of a Context. These are pretty much the equivalent of a Service Object in a more functional language like Elixir.

I think Phoenix’s approach is much simpler and, if you also add in the recommended error handling strategy, you end up with more elegant and less boilerplatey code. The good news it isn’t tied to Elixir’s constructs and that is translates very easily to Ruby.

module Users
  extend self
  
  def create_user(email)
    user = User.new(email:)
    return [:invalid, user] if user.invalid?
    
    UserMailer.with(user:).welcome_email.deliver_later
    
    [:ok, user]
  end
end

# Usage
case Users.create_user(params[:email])
in [:ok, user]
  ...
in [:invalid, user]
  ...
end

The code is easy to understand and all of it is needed there’s not boilerplate and no yaks to shave. Better, no?



from Hacker News https://ift.tt/U4wd3Ma

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.