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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,8 @@ cookbooks
# Autoscaling simulation output
/scaling-graph.csv
/scaling-graph.png

# Terraform
**/.terraform/
**/.terraform.lock.hcl
**/og_router.zip
14 changes: 14 additions & 0 deletions app/controllers/cdn_spa_shell_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

# Serves a minimal SPA shell with only convention-level Open Graph metadata.
# Intended as the CloudFront origin for regular (non-crawler) user requests;
# crawler requests are routed to OgShellController by a Lambda@Edge function.
class CdnSpaShellController < ApplicationController
skip_before_action :ensure_user_con_profile_exists
skip_before_action :redirect_if_user_con_profile_needs_update
skip_before_action :ensure_clickwrap_agreement_accepted

def show
render html: "".html_safe, layout: "cdn_spa_shell"
end
end
20 changes: 20 additions & 0 deletions app/controllers/og_shell_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

# Returns a minimal HTML document with Open Graph meta tags for a given path.
# Intended to be served to crawlers by a CDN edge function; regular browsers
# receive the static SPA shell instead.
class OgShellController < ApplicationController
include CmsContentHelpers

skip_before_action :ensure_user_con_profile_exists
skip_before_action :redirect_if_user_con_profile_needs_update
skip_before_action :ensure_clickwrap_agreement_accepted

def show
path = params[:path].presence || "/"
@og_url = "#{request.scheme}://#{request.host_with_port}#{path}"
@event = event_for_path(path)
@page = current_cms_page(path)
render layout: false
end
end
32 changes: 32 additions & 0 deletions app/views/layouts/cdn_spa_shell.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/>
<title><%= convention ? convention.name : "Intercode" %></title>
<% if Rails.env.development? %>
<script type="module">
import RefreshRuntime from <%=raw url_with_possible_host("/@react-refresh", ENV['ASSETS_HOST']).to_json %>
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
<%= javascript_include_tag url_with_possible_host("/@vite/client", ENV['ASSETS_HOST']), type: 'module' %>
<% end %>
<%= javascript_include_tag url_with_possible_host(application_entry_path, ENV['ASSETS_HOST']), defer: true, type: 'module' %>
<meta content="width=device-width, initial-scale=1" name="viewport"/>
<meta property="og:url" content="<%= request.url %>">
<meta property="og:title" content="<%= convention ? convention.name : "Intercode" %>">
<meta property="og:description" content="">
<% if convention&.open_graph_image&.attachment -%>
<meta property="og:image" content="<%= cdn_upload_url(convention.open_graph_image) %>">
<% end -%>
<meta property="og:type" content="website"/>
<% if convention&.favicon&.attachment -%>
<link rel="icon" type="<%= convention.favicon.content_type %>" href="<%= cdn_upload_url(convention.favicon) %>">
<% end -%>
</head>
<body>
<%= yield %>
</body>
</html>
29 changes: 29 additions & 0 deletions app/views/og_shell/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/>
<% if @event -%>
<title><%= @event.title %></title>
<meta property="og:title" content="<%= @event.title %>">
<meta name="description" content="<%= strip_tags @event.short_blurb %>">
<meta property="og:description" content="<%= strip_tags @event.short_blurb %>">
<% elsif @page -%>
<title><%= @page.name %><%= " - #{convention.name}" if convention %></title>
<meta property="og:title" content="<%= @page.name %>">
<% if @page.cached_og_description.present? -%>
<meta name="description" content="<%= @page.cached_og_description %>">
<meta property="og:description" content="<%= @page.cached_og_description %>">
<% end -%>
<% elsif convention -%>
<title><%= convention.name %></title>
<meta property="og:title" content="<%= convention.name %>">
<% end -%>
<meta property="og:url" content="<%= @og_url %>">
<meta property="og:type" content="website"/>
<% if convention&.open_graph_image&.attachment -%>
<meta property="og:image" content="<%= cdn_upload_url(convention.open_graph_image) %>">
<% end -%>
<link rel="canonical" href="<%= @og_url %>">
</head>
<body></body>
</html>
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
devise_for :users, controllers: { passwords: "passwords", registrations: "registrations", sessions: "sessions" }
get "/authenticity_tokens", to: "authenticity_tokens#show"
get "/client_configuration", to: "client_configuration#show"
get "/cdn-spa-shell", to: "cdn_spa_shell#show"
get "/og-shell", to: "og_shell#show"
post "/oauth_session/exchange", to: "oauth_sessions#exchange"
post "/oauth_session/refresh", to: "oauth_sessions#refresh"
post "/oauth_session/sign_out", to: "oauth_sessions#sign_out"
Expand Down
54 changes: 54 additions & 0 deletions lambda/cloudfront-og-router/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# cloudfront-og-router

Lambda@Edge origin-request handler that routes CloudFront traffic to the
appropriate Intercode endpoint based on whether the request is from a crawler.

## How it works

| Request type | Rewrites to |
| ------------------------ | ------------------------------------ |
| Known crawler User-Agent | `GET /og-shell?path=<original-path>` |
| Everyone else | `GET /cdn-spa-shell` |

`/og-shell` returns a minimal HTML document with per-resource Open Graph tags
(event title/description, page `cached_og_description`, convention OG image).

`/cdn-spa-shell` returns a lightweight SPA shell with convention-level OG only
and no per-path DB lookups — suitable for long-term CDN caching.

## CloudFront distribution setup

The function attaches to the **origin request** event of the **default cache
behaviour** (i.e. the catch-all for SPA routes). More-specific behaviours must
be defined first so they take precedence:

| Path pattern | Origin | Lambda@Edge | Cache TTL |
| ----------------------- | ------------- | ------------------------------ | ------------------------------------------- |
| `/packs/*` | Assets server | None | Long (immutable) |
| `/client_configuration` | Rails | None | No cache |
| `/authenticity_tokens` | Rails | None | No cache |
| `/oauth_session/*` | Rails | None | No cache |
| `/og-shell` | Rails | None | Medium (keyed on `path` query param + Host) |
| `/cdn-spa-shell` | Rails | None | Long (keyed on Host) |
| `*` (default) | Rails | This function (origin request) | Long (keyed on Host) |

### Cache key notes

- `/cdn-spa-shell`: include the `Host` header in the cache key (content varies
per convention domain). TTL of several hours is fine — cache is busted
whenever the convention's name or OG image changes by deploying a new
container.
- `/og-shell`: include `Host` + the `path` query parameter. TTL of ~1 hour is
reasonable; `cached_og_description` is refreshed in the background on each
page save.
- Default behaviour: long TTL is fine since the function always rewrites to one
of the above two cached paths.

## Deployment

The function must be deployed to **us-east-1** (Lambda@Edge requirement) and
associated with the CloudFront distribution. No dependencies — zip just
`index.mjs` and `package.json`.

Runtime: `nodejs22.x`
Handler: `index.handler`
39 changes: 39 additions & 0 deletions lambda/cloudfront-og-router/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Lambda@Edge origin-request handler for Intercode CloudFront distribution.
//
// CloudFront behaviour: attach to the default cache behaviour, which only
// fires after all more-specific behaviours (Rails routes, /packs/*, /og-shell,
// /cdn-spa-shell, etc.) have already been evaluated and not matched.
//
// Logic:
// - Known crawler User-Agents → /og-shell?path=<original-path>
// - Everyone else → /cdn-spa-shell

const CRAWLER_UA_RE =
/Googlebot|bingbot|DuckDuckBot|YandexBot|Baiduspider|facebookexternalhit|Twitterbot|LinkedInBot|WhatsApp|Discordbot|Slackbot|Applebot|Embedly|Pinterest/i;

// Paths that belong to the underlying Rails app and must never be rewritten.
// This mirrors the specific CloudFront behaviours defined in the Terraform
// module and acts as a safety net if a new route is added without a
// corresponding behaviour.
const RAILS_PATH_RE =
/^\/(users|oauth|oauth_session|authenticity_tokens|client_configuration|graphql|graphiql|email_forwarders|sns_notifications|stripe_webhook|stripe_account|healthz|sitemap\.xml|reports|calendars|csv_exports|user_con_profiles|rails|uploads|packs|cdn-spa-shell|og-shell)(\/|$)/;

export const handler = async (event) => {
const { request } = event.Records[0].cf;
const ua = request.headers["user-agent"]?.[0]?.value ?? "";
const originalPath = request.uri;

if (RAILS_PATH_RE.test(originalPath)) {
return request;
}

if (CRAWLER_UA_RE.test(ua)) {
request.uri = "/og-shell";
request.querystring = `path=${encodeURIComponent(originalPath)}`;
} else {
request.uri = "/cdn-spa-shell";
request.querystring = "";
}

return request;
};
7 changes: 7 additions & 0 deletions lambda/cloudfront-og-router/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "cloudfront-og-router",
"version": "1.0.0",
"description": "Lambda@Edge origin-request handler for Intercode CloudFront distribution",
"type": "module",
"main": "index.mjs"
}
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,6 @@
"vite": "^8.0.0",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-node-polyfills": "^0.28.0",
"vite-tsconfig-paths": "^6.0.0",
"vitest": "^4.0.0",
"wrangler": "^4.59.1"
},
Expand Down
79 changes: 79 additions & 0 deletions terraform/modules/cloudfront_intercode/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# cloudfront_intercode Terraform module

Creates a CloudFront distribution in front of an Intercode Rails origin with:

- Long-TTL caching of `/packs/*` from the assets server
- No-cache pass-through for API/auth endpoints
- Crawler detection via a Lambda@Edge origin-request function:
- Crawlers → `/og-shell?path=<original-path>` (per-resource OG metadata)
- Regular users → `/cdn-spa-shell` (lightweight shell, convention-level OG only)

## Usage

```hcl
provider "aws" {
region = "us-east-1"
alias = "us_east_1"
}

module "cloudfront" {
source = "path/to/modules/cloudfront_intercode"

providers = {
aws = aws # your default provider
aws.us_east_1 = aws.us_east_1
}

name = "intercode-production"
rails_origin_domain = "www.neilhosting.net"
assets_origin_domain = "assets.neilhosting.net"
aliases = ["interconx.com", "www.interconx.com"]
acm_certificate_arn = "arn:aws:acm:us-east-1:123456789:certificate/abc-123"
}

# Point convention domains at the distribution.
resource "aws_route53_record" "interconx" {
zone_id = data.aws_route53_zone.interconx.zone_id
name = "interconx.com"
type = "A"

alias {
name = module.cloudfront.distribution_domain_name
zone_id = module.cloudfront.distribution_hosted_zone_id
evaluate_target_health = false
}
}
```

## Provider aliases

Lambda@Edge functions must be deployed in `us-east-1`. This module requires a
provider alias named `aws.us_east_1` configured for that region. Pass it via
the `providers` map as shown above.

## Host header forwarding

Intercode uses the `Host` header to resolve the convention for each request.
The origin-request policies in this module forward the viewer's `Host` header
to the Rails origin, so multi-tenant routing works correctly behind CloudFront.

## Inputs

| Name | Description | Default |
| ---------------------- | -------------------------------------------- | ---------------- |
| `name` | Resource name prefix | — |
| `rails_origin_domain` | Rails app domain | — |
| `assets_origin_domain` | Assets server domain | — |
| `aliases` | CloudFront CNAMEs | `[]` |
| `acm_certificate_arn` | ACM certificate ARN (us-east-1) | `null` |
| `price_class` | CloudFront price class | `PriceClass_100` |
| `og_shell_ttl` | TTL for `/og-shell` responses (seconds) | `3600` |
| `cdn_spa_shell_ttl` | TTL for `/cdn-spa-shell` responses (seconds) | `86400` |

## Outputs

| Name | Description |
| ----------------------------- | ------------------------------------------ |
| `distribution_id` | CloudFront distribution ID |
| `distribution_domain_name` | Use as CNAME target for convention domains |
| `distribution_hosted_zone_id` | Use for Route 53 alias records |
55 changes: 55 additions & 0 deletions terraform/modules/cloudfront_intercode/lambda.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
data "archive_file" "og_router" {
type = "zip"
output_path = "${path.module}/og_router.zip"

source {
content = file("${path.module}/../../../lambda/cloudfront-og-router/index.mjs")
filename = "index.mjs"
}

source {
content = file("${path.module}/../../../lambda/cloudfront-og-router/package.json")
filename = "package.json"
}
}

resource "aws_iam_role" "lambda_edge" {
name = "${var.name}-lambda-edge"

assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Service = [
"lambda.amazonaws.com",
"edgelambda.amazonaws.com",
]
}
Action = "sts:AssumeRole"
}
]
})
}

resource "aws_iam_role_policy_attachment" "lambda_edge_basic" {
role = aws_iam_role.lambda_edge.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

# Lambda@Edge functions must be deployed in us-east-1.
resource "aws_lambda_function" "og_router" {
provider = aws.us_east_1

function_name = "${var.name}-og-router"
filename = data.archive_file.og_router.output_path
source_code_hash = data.archive_file.og_router.output_base64sha256
role = aws_iam_role.lambda_edge.arn
handler = "index.handler"
runtime = "nodejs22.x"

# Lambda@Edge requires a numbered version — publish = true creates one on
# every deployment.
publish = true
}
Loading
Loading