diff --git a/app/controllers/static_controller.rb b/app/controllers/static_controller.rb index e8aa53da..e7050b96 100644 --- a/app/controllers/static_controller.rb +++ b/app/controllers/static_controller.rb @@ -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 @@ -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? diff --git a/app/mailers/levelcode_billing_mailer.rb b/app/mailers/levelcode_billing_mailer.rb index 64aedbe0..b16e31e4 100644 --- a/app/mailers/levelcode_billing_mailer.rb +++ b/app/mailers/levelcode_billing_mailer.rb @@ -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 diff --git a/app/services/levelcode/hosts.rb b/app/services/levelcode/hosts.rb new file mode 100644 index 00000000..e06dca49 --- /dev/null +++ b/app/services/levelcode/hosts.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 1e1fe295..ac4d65a3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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/") } diff --git a/spec/requests/static_spec.rb b/spec/requests/static_spec.rb index cf3b2b4e..3d78d75a 100644 --- a/spec/requests/static_spec.rb +++ b/spec/requests/static_spec.rb @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/spec/services/levelcode/hosts_spec.rb b/spec/services/levelcode/hosts_spec.rb new file mode 100644 index 00000000..fa9b92ac --- /dev/null +++ b/spec/services/levelcode/hosts_spec.rb @@ -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