Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 25 additions & 15 deletions app/controllers/static_controller.rb
Original file line number Diff line number Diff line change
@@ -1,29 +1,30 @@
class StaticController < ApplicationController
# Hosts that serve the LevelCode Cloud account app (levelcode.html shell) instead of
# the thin.ly shortener SPA. The account app always lives under /ai/* on every
# host (the /ai prefix never collides with the 7-char shortcode lookup), so the
# brand switch is PATH-based; these hosts additionally funnel bare HTML paths
# into /ai so levelcode.ai/pricing → /ai/pricing. (Non-HTML bare paths 404, which
# is correct — the account app has no non-HTML bare endpoints; the JSON API is
# under /api/levelcode/v1/*.)
LEVELCODE_HOSTS = %w[levelcode.ai www.levelcode.ai].freeze
# Canonical origin LevelCode Cloud lives on. thin.ly bounces any /ai request here so the account app
# only ever opens on a LevelCode host. ENV-overridable for staging.
LEVELCODE_ORIGIN = ENV.fetch("LEVELCODE_ORIGIN", "https://levelcode.ai").freeze

# Strict host↔brand isolation:
# • a LevelCode host serves ONLY the account app (levelcode shell); bare paths funnel into /ai and
# it never renders the thin.ly shortener shell.
# • the thin.ly (shortener) host NEVER serves the account app; any /ai request is bounced to the
# canonical LevelCode origin so LevelCode Cloud can't be opened from thin.ly/ai.
# Which hosts are LevelCode hosts, and where the canonical origin is, is Levelcode::Hosts' answer.
#
# The account app always lives under /ai/* on every host (the /ai prefix never collides with the
# 7-char shortcode lookup), so the brand switch is PATH-based; LevelCode hosts additionally funnel
# bare HTML paths into /ai so levelcode.ai/pricing → /ai/pricing. (Non-HTML bare paths 404, which
# is correct — the account app has no non-HTML bare endpoints; the JSON API is under
# /api/levelcode/v1/*.)
def ui
if levelcode_host?
return redirect_to(ai_funnel_dest) unless ai_path?

render "static/ui_levelcode", layout: false
else
return redirect_to("#{LEVELCODE_ORIGIN}#{request.fullpath}", allow_other_host: true, status: :moved_permanently) if ai_path?
elsif ai_path?
# Never bounce a host to itself. If the canonical origin resolves to the host already being
# asked, a 301 here is an infinite loop — the browser follows it straight back to this action.
# That is what a half-applied staging override produces (LEVELCODE_ORIGIN set, LEVELCODE_HOSTS
# not), and a config mistake should degrade to "serves the app" rather than to a redirect storm.
return render("static/ui_levelcode", layout: false) if origin_is_self?

redirect_to "#{levelcode_hosts.origin}#{request.fullpath}", allow_other_host: true, status: :moved_permanently
else
render "static/ui", layout: false
end
end
Expand All @@ -45,8 +46,17 @@ def not_found

private

def levelcode_hosts
Levelcode::Hosts.current
end

def levelcode_host?
LEVELCODE_HOSTS.include?(request.host)
levelcode_hosts.include?(request.host)
end

# True when the canonical origin IS this request's host — i.e. redirecting would target ourselves.
def origin_is_self?
levelcode_hosts.origin?(request.host)
end

def ai_path?
Expand Down
2 changes: 1 addition & 1 deletion app/mailers/levelcode_billing_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
class LevelcodeBillingMailer < ApplicationMailer
layout false

# The one Rails host serves the account SPA under /ai (see StaticController::LEVELCODE_HOSTS).
# The one Rails host serves the account SPA under /ai (see Levelcode::Hosts).
LEVELCODE_SITE = "https://levelcode.ai"

# LevelCode-branded sender, overriding the thin.ly default. Point LEVELCODE_MAIL_FROM at a verified
Expand Down
69 changes: 69 additions & 0 deletions app/services/levelcode/hosts.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

module Levelcode
# Which hosts serve the LevelCode Cloud account app, and where it canonically lives.
#
# One Rails app answers for two brands: thin.ly, the link shortener, and LevelCode Cloud, the account
# app (levelcode.html shell) under /ai. This is the one answer to "which brand is this host?". The
# route constraint that keeps the 7-char shortcode lookup off LevelCode hosts and StaticController#ui
# both ask it, so routing and dispatch cannot disagree about a host.
#
# Both settings are ENV-overridable so a staging or tunnel host (ngrok, a preview deploy) can serve
# the account app:
#
# LEVELCODE_HOSTS comma list of hosts that serve the account app instead of the shortener SPA
# LEVELCODE_ORIGIN the canonical origin any /ai request on another host is bounced to
#
# Set them TOGETHER. Overriding the origin alone points the bounce at a host that is still not a
# LevelCode host, and /ai would 301 to itself forever; #origin? is how StaticController#ui notices
# that and renders instead of looping.
class Hosts
DEFAULT_HOSTS = "levelcode.ai,www.levelcode.ai"
DEFAULT_ORIGIN = "https://levelcode.ai"

class << self
# The policy for this process, read from the environment once.
def current
@current ||= from_env
end

def from_env(env = ENV)
new(
hosts: env.fetch("LEVELCODE_HOSTS", DEFAULT_HOSTS),
origin: env.fetch("LEVELCODE_ORIGIN", DEFAULT_ORIGIN)
)
end
end

attr_reader :hosts, :origin

# hosts: a comma list — entries are trimmed and case-folded, blanks dropped.
# origin: a URL. Only its host is ever compared; the string itself is the redirect target.
def initialize(hosts:, origin:)
@hosts = hosts.to_s.split(",").map { |h| h.strip.downcase }.reject(&:empty?).freeze
@origin = origin.to_s.freeze
@origin_host = host_of(@origin)
freeze
end

# Does `host` serve the account app? Case is folded here because Rack hands the Host header through
# exactly as the client sent it (only the port is stripped) — `LevelCode.AI` is a LevelCode host.
def include?(host)
hosts.include?(host.to_s.downcase)
end

# Is `host` the canonical origin's own host — i.e. would bouncing it to #origin target itself?
def origin?(host)
!@origin_host.nil? && @origin_host.casecmp?(host.to_s) == true
end

private

# nil when the origin cannot be parsed as a URL, which then matches no host.
def host_of(url)
URI.parse(url).host.to_s
rescue URI::InvalidURIError
nil
end
end
end
3 changes: 2 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@
# account app (not a shortener), so a 7-char bare path there (e.g. /pricing)
# must fall through to static#ui — which redirects it into the /ai app — instead
# of resolving as a short code.
constraints(->(req) { !StaticController::LEVELCODE_HOSTS.include?(req.host) }) do
# Levelcode::Hosts is the same answer static#ui acts on, so routing and dispatch cannot disagree.
constraints(->(req) { !Levelcode::Hosts.current.include?(req.host) }) do
get "/:lookup_code" => "api/v1/links#lookup_code", as: :lookup_code, constraints: { lookup_code: /[a-zA-Z0-9]{7}/ }
end
match "*ui", to: "static#ui", via: :get, constraints: ->(request) { request.format.html? && !request.path.start_with?("/api/") }
Expand Down
80 changes: 80 additions & 0 deletions spec/requests/static_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,22 @@
# Strict host <-> brand isolation (StaticController#ui): thin.ly serves ONLY the shortener,
# levelcode.ai serves ONLY the LevelCode Cloud account app.
RSpec.describe "Static host/brand isolation", type: :request do
# The two shells. A 200 cannot tell them apart, and telling them apart is the point of this file.
let(:shortener_shell) { "static/ui" }
let(:account_shell) { "static/ui_levelcode" }

# Run an example under a different host policy — what LEVELCODE_HOSTS / LEVELCODE_ORIGIN would set.
def with_levelcode_hosts(hosts: Levelcode::Hosts::DEFAULT_HOSTS, origin: Levelcode::Hosts::DEFAULT_ORIGIN)
allow(Levelcode::Hosts).to receive(:current).and_return(Levelcode::Hosts.new(hosts: hosts, origin: origin))
end

describe "on the thin.ly (shortener) host" do
before { host! "thin.ly" }

it "serves the shortener shell at the root" do
get "/"
expect(response).to have_http_status(:ok)
expect(response).to render_template(shortener_shell)
end

it "does NOT open LevelCode Cloud from /ai — it 301s to the canonical LevelCode origin" do
Expand All @@ -21,6 +31,13 @@
get "/ai/account?tab=usage"
expect(response).to redirect_to("https://levelcode.ai/ai/account?tab=usage")
end

# The inverse of every "funnels a 7-char bare path" example below: here it IS a short code. Without
# this, a route constraint that withheld the lookup from every host would pass the whole file.
it "resolves a 7-char bare path as a short code — the lookup is withheld only from LevelCode hosts" do
get "/abc1234"
expect(response).to redirect_to("/link-not-found")
end
end

describe "on the levelcode.ai (account app) host" do
Expand All @@ -29,6 +46,7 @@
it "renders the LevelCode Cloud shell under /ai" do
get "/ai/account"
expect(response).to have_http_status(:ok)
expect(response).to render_template(account_shell)
end

it "never serves the shortener shell — a bare path funnels into /ai" do
Expand All @@ -41,4 +59,66 @@
expect(response).to redirect_to("/ai")
end
end

# Rack passes `req.host` through exactly as the client sent it (only the port is stripped), so
# routing and the controller both have to fold case — or a 7-char bare path like /pricing
# resolves as a short code before #ui ever runs.
describe "on a LevelCode host sent with a mixed-case Host header" do
before { host! "LevelCode.AI" }

it "funnels a 7-char bare path into /ai — routing and the controller agree on the host" do
get "/pricing"
expect(response).to redirect_to("/ai/pricing")
expect(response).not_to redirect_to("/link-not-found")
end

it "renders the LevelCode Cloud shell under /ai" do
get "/ai/account"
expect(response).to have_http_status(:ok)
expect(response).to render_template(account_shell)
end
end

# A half-applied staging override — LEVELCODE_ORIGIN pointed at a tunnel while LEVELCODE_HOSTS still
# listed only production — made /ai/login 301 to itself, forever. The browser follows the redirect
# straight back into this action and the log fills with identical 301s.
describe "when the canonical origin IS the host being asked" do
before do
with_levelcode_hosts(origin: "https://thinly.ngrok.app")
host! "thinly.ngrok.app"
end

it "serves the account app instead of redirecting to itself" do
get "/ai/login"
expect(response).to have_http_status(:ok)
expect(response).not_to have_http_status(:moved_permanently)
expect(response).to render_template(account_shell)
end

it "still serves the shortener shell on a non-/ai path" do
get "/"
expect(response).to have_http_status(:ok)
expect(response).to render_template(shortener_shell)
end
end

describe "on a host that LEVELCODE_HOSTS adds (a tunnel or staging host)" do
before do
with_levelcode_hosts(hosts: "levelcode.ai,www.levelcode.ai,thinly.ngrok.app")
host! "thinly.ngrok.app"
end

it "serves the account app under /ai" do
get "/ai/login"
expect(response).to have_http_status(:ok)
expect(response).to render_template(account_shell)
end

# /pricing is 7 characters, so this only passes if ROUTING consults the same list the controller
# does: the tunnel host exists nowhere but in the overridden policy.
it "funnels a 7-char bare path into /ai, exactly as production does" do
get "/pricing"
expect(response).to redirect_to("/ai/pricing")
end
end
end
98 changes: 98 additions & 0 deletions spec/services/levelcode/hosts_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe Levelcode::Hosts do
subject(:policy) do
described_class.new(hosts: "levelcode.ai, WWW.LevelCode.ai ,thinly.ngrok.app,", origin: "https://LevelCode.ai")
end

describe "the host list" do
it "is parsed from a comma list — trimmed, case-folded, blanks dropped" do
expect(policy.hosts).to eq(%w[levelcode.ai www.levelcode.ai thinly.ngrok.app])
end

it "is empty for an empty or missing list" do
expect(described_class.new(hosts: "", origin: "https://levelcode.ai").hosts).to eq([])
expect(described_class.new(hosts: nil, origin: "https://levelcode.ai").hosts).to eq([])
end
end

describe "#include?" do
it "folds case — Rack hands the Host header through exactly as the client sent it" do
expect(policy.include?("levelcode.ai")).to be(true)
expect(policy.include?("LevelCode.AI")).to be(true)
expect(policy.include?("WWW.LEVELCODE.AI")).to be(true)
end

it "is false for the shortener host, nothing, and nil" do
expect(policy.include?("thin.ly")).to be(false)
expect(policy.include?("")).to be(false)
expect(policy.include?(nil)).to be(false)
end
end

describe "#origin?" do
it "is true for the canonical origin's own host, whatever the case" do
expect(policy.origin?("levelcode.ai")).to be(true)
expect(policy.origin?("LEVELCODE.AI")).to be(true)
end

it "is false for any other host, and for nil" do
expect(policy.origin?("thin.ly")).to be(false)
expect(policy.origin?("www.levelcode.ai")).to be(false)
expect(policy.origin?(nil)).to be(false)
end

it "compares the origin's host only — scheme, port and path are not part of the question" do
tunnel = described_class.new(hosts: "", origin: "https://thinly.ngrok.app:8443/ai")
expect(tunnel.origin?("thinly.ngrok.app")).to be(true)
expect(tunnel.origin).to eq("https://thinly.ngrok.app:8443/ai")
end

it "is false — never an exception — when the origin is not a URL at all" do
broken = described_class.new(hosts: "levelcode.ai", origin: "http://not a url")
expect(broken.origin?("levelcode.ai")).to be(false)
expect(broken.origin?("not a url")).to be(false)
end
end

# ENV is passed in rather than read, so "the list really comes from the environment" is an
# assertion about behaviour. The constants this replaced were frozen at class load, and the only
# way to check that was a regex over the controller's source.
describe ".from_env" do
it "reads both settings from the environment it is given" do
policy = described_class.from_env("LEVELCODE_HOSTS" => "a.test, B.test", "LEVELCODE_ORIGIN" => "https://a.test")
expect(policy.hosts).to eq(%w[a.test b.test])
expect(policy.origin).to eq("https://a.test")
end

it "falls back to the production defaults when a setting is absent" do
policy = described_class.from_env({})
expect(policy.hosts).to eq(%w[levelcode.ai www.levelcode.ai])
expect(policy.origin).to eq("https://levelcode.ai")
end

# LEVELCODE_ORIGIN set, LEVELCODE_HOSTS not: the tunnel is the origin without being a LevelCode
# host. That gap is exactly what StaticController#ui's self-redirect guard exists for.
it "takes each setting independently, so a half-applied override is representable" do
policy = described_class.from_env("LEVELCODE_ORIGIN" => "https://thinly.ngrok.app")
expect(policy.include?("thinly.ngrok.app")).to be(false)
expect(policy.origin?("thinly.ngrok.app")).to be(true)
end
end

describe ".current" do
it "is built from the environment, once" do
fresh = Class.new(described_class) # its own memo, so the process-wide policy is never disturbed
expect(fresh).to receive(:from_env).once.and_call_original
expect(fresh.current).to equal(fresh.current)
expect(fresh.current).to be_a(described_class)
end
end

it "is immutable" do
expect(policy).to be_frozen
expect(policy.hosts).to be_frozen
end
end
Loading