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.
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.