Skip to content

Commit

Permalink
Cloudflare edge caching
Browse files Browse the repository at this point in the history
  • Loading branch information
dbackeus committed Jun 10, 2024
1 parent ba7ed47 commit 711c9bd
Show file tree
Hide file tree
Showing 10 changed files with 267 additions and 14 deletions.
2 changes: 2 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CLOUDFLARE_API_TOKEN=cloudflare-token
CLOUDFLARE_ZONE_ID=zone-id
47 changes: 47 additions & 0 deletions app/api/cloudflare.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
module Cloudflare
BASE_URL = "https://api.cloudflare.com/client/v4".freeze

# https://developers.cloudflare.com/api/operations/zone-purge#purge-cached-content-by-tag-host-or-prefix
#
# Rate-limiting: Cache-Tag, host and prefix purging each have a rate limit
# of 30,000 purge API calls in every 24 hour period. You may purge up to
# 30 tags, hosts, or prefixes in one API call. This rate limit can be
# raised for customers who need to purge at higher volume.
#
# Provide tags as an Array of Strings, eg: ["mnd-assets-id-xxx", ...] or a single String
def self.purge_by_tags(tags, zone_id: ENV.fetch("CLOUDFLARE_ZONE_ID"))
tags = Array.wrap(tags)

post("zones/#{zone_id}/purge_cache", tags:)
end

# https://developers.cloudflare.com/api/operations/zone-purge#purge-cached-content-by-url
def self.purge_by_urls(urls, zone_id: ENV.fetch("CLOUDFLARE_ZONE_ID"))
urls = Array.wrap(urls)

post("zones/#{zone_id}/purge_cache", files: urls)
end

# https://developers.cloudflare.com/api/operations/zone-purge#purge-all-cached-content
def self.purge_everything(zone_id: ENV.fetch("CLOUDFLARE_ZONE_ID"))
post("zones/#{zone_id}/purge_cache", purge_everything: true)
end

%w[get post delete patch].each do |verb|
define_singleton_method(verb) do |path, params = {}|
request(verb.upcase, path, params)
end
end

def self.request(verb, path, params)
HTTPX.send(
verb.downcase,
"#{BASE_URL}/#{path}",
headers: {
"Authorization" => "Bearer #{ENV.fetch('CLOUDFLARE_API_TOKEN')}",
"Accept" => "application/json",
},
json: params,
).raise_for_status
end
end
6 changes: 6 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
class ApplicationController < ActionController::Base
if ENV["CLOUDFLARE_WORKER_HOST"].present?
def redirect_to(options = {}, response_options = {})
response_options[:allow_other_host] = true unless response_options.key?(:allow_other_host)
super(options, response_options)
end
end
end
45 changes: 32 additions & 13 deletions app/controllers/posts_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class PostsController < ApplicationController
before_action :set_post, only: %i[ show edit update destroy ]
before_action :enable_caching, only: %i[index show new edit]
skip_before_action :verify_authenticity_token

# GET /posts
def index
Expand All @@ -8,6 +9,7 @@ def index

# GET /posts/1
def show
@post = Post.find(params[:id])
end

# GET /posts/new
Expand All @@ -17,42 +19,59 @@ def new

# GET /posts/1/edit
def edit
@post = Post.find(params[:id])
end

# POST /posts
def create
@post = Post.new(post_params)

if @post.save
redirect_to @post, notice: "Post was successfully created."
CachedUrl.expire_by_tags(["posts:all"])
redirect_to post_url(@post, nocache: true), notice: "Post was successfully created."
else
render :new, status: :unprocessable_entity
end
end

# PATCH/PUT /posts/1
def update
@post = Post.find(params[:id])

if @post.update(post_params)
redirect_to @post, notice: "Post was successfully updated."
CachedUrl.expire_by_tags(["posts:all", "posts:#{@post.id}"])
redirect_to post_url(@post, nocache: true), notice: "Post was successfully updated."
else
render :edit, status: :unprocessable_entity
end
end

# DELETE /posts/1
def destroy
@post.destroy!
redirect_to posts_url, notice: "Post was successfully destroyed.", status: :see_other
post = Post.find(params[:id])

post.destroy!

CachedUrl.expire_by_tags(["posts:all", "posts:#{post.id}"])

redirect_to posts_url(nocache: true), notice: "Post was successfully destroyed.", status: :see_other
end

private
# Use callbacks to share common setup or constraints between actions.
def set_post
@post = Post.find(params[:id])
end

# Only allow a list of trusted parameters through.
def post_params
params.require(:post).permit(:title, :body)
end
def post_params
params.require(:post).permit(:title, :body)
end

def enable_caching
return if params.key?(:nocache)

# don't cache cookies (note: Cloudflare won't cache responses with cookies)
request.session_options[:skip] = true

tags = action_name == "index" ? ["section:posts", "posts:all"] : ["section:posts", "posts:#{params[:id]}"]

CachedUrl.upsert({ url: request.url, tags:, expires_at: 1.hour.from_now }, unique_by: :url)
expires_in 1.hour, public: true
end
end
16 changes: 16 additions & 0 deletions app/models/cached_url.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
class CachedUrl < ApplicationRecord
scope :tagged_one_of, -> (tags) { where("tags && ARRAY[?]::varchar[]", tags) }

def self.expire_by_tags(tags)
transaction do
cached_urls = tagged_one_of(tags)

now = Time.now
urls_to_purge = cached_urls.map { |cu| cu.url unless cu.expires_at < now }.compact

Cloudflare.purge_by_urls(urls_to_purge)

cached_urls.delete_all
end
end
end
111 changes: 111 additions & 0 deletions cloudflare-csrf-worker/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// In a production setting these would be set as environment variables in the Cloudflare dashboard
const SECRET = "0c55b6b18a5072a6ba83773679c6a114234798c1be4d8591f628023e9475f11300d97ccc14fd4293cfb038b1253937704e9311677610a15875a48899bc70be91"
const ORIGIN_HOST = "rails-example.staging.mynewsdesk.dev"

addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})

encodeText = text => new TextEncoder().encode(text) // text to ArrayBuffer
decodeText = buffer => new TextDecoder().decode(buffer) // ArrayBuffer to text

// Function to generate a crypto key from a secret key
async function generateCryptoKey(secretKey) {
return await crypto.subtle.importKey(
'raw',
encodeText(secretKey),
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt']
)
}

// SHA256 the secret to ensure it's the correct length
async function hashSecretKey(secretKey) {
return await crypto.subtle.digest('SHA-256', encodeText(secretKey))
}

function generateRandomToken() {
return Array.from(crypto.getRandomValues(new Uint8Array(32)))
.map(int => int.toString(16).padStart(2, '0'))
.join('')
}

function getCookieValue(cookieString, name) {
const cookies = cookieString.split('; ')
for (const cookie of cookies) {
const [cookieName, cookieValue] = cookie.split('=')
if (cookieName === name) {
return cookieValue
}
}
return null
}

async function encryptMessage(secretKey, message) {
const iv = crypto.getRandomValues(new Uint8Array(12)); // Initialization vector
const key = await generateCryptoKey(secretKey)
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encodeText(message))

// Combine iv and encrypted data
const encryptedArray = new Uint8Array(encrypted)
const combinedArray = new Uint8Array(iv.length + encryptedArray.length)
combinedArray.set(iv, 0)
combinedArray.set(encryptedArray, iv.length)

return btoa(String.fromCharCode(...combinedArray))
}

async function decryptMessage(secretKey, encryptedMessage) {
const combinedArray = new Uint8Array(atob(encryptedMessage).split('').map(char => char.charCodeAt(0)))
const iv = combinedArray.slice(0, 12)
const encryptedArray = combinedArray.slice(12)

const key = await generateCryptoKey(secretKey)
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encryptedArray)

return decodeText(decrypted)
}

async function handleRequest(request, env, ctx) {
const requestUrl = new URL(request.url)
requestUrl.hostname = ORIGIN_HOST

const secretKey = hashSecretKey(SECRET)

if (request.method == 'POST' || request.method == 'PUT' || request.method == 'DELETE' || request.method == 'PATCH') {
// Read form data from a clone to avoid corrupting the original request before we forward it.
const clonedRequest = request.clone()
const form = await clonedRequest.formData()
const csrfToken = form.get('authenticity_token')
if (!csrfToken) return new Response(`CSRF token not found!\n${form}`, { status: 403 })

const cookieToken = getCookieValue(request.headers.get('Cookie'), 'csrf_token')
const decryptedToken = await decryptMessage(secretKey, cookieToken)

// console.log('csrfToken:', csrfToken)
// console.log('cookieToken:', cookieToken)
// console.log('decryptedToken:', decryptedToken)

if (csrfToken != decryptedToken) return new Response('CSRF validation failed!', { status: 403 })
}

const response = await fetch(requestUrl, request)
let html = await response.text()

// If the response doesn't contain a CSRF token, we don't need to do anything
if(!html.includes('<meta name="csrf-token" content=')) return new Response(html, response)

// Replace CSRF tokens present in <meta> and <input> tags provided by Rails with one generated by the worker
const token = generateRandomToken()
html = html
.replace(/<meta name="csrf-token" content=".*"/, `<meta name="csrf-token" content="${token}"`)
.replace(/<input type="hidden" name="authenticity_token" value=".*"/, `<input type="hidden" name="authenticity_token" value="${token}"`)

// Encrypt the token and set it as a cookie
const encryptedToken = await encryptMessage(secretKey, token)
const modifiedResponse = new Response(html, response)
modifiedResponse.headers.append('Set-Cookie', `csrf_token=${encryptedToken}; path=/; HttpOnly; Secure; SameSite=Lax`)

return modifiedResponse
}
4 changes: 4 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,9 @@ class Application < Rails::Application
config.hosts.clear

config.active_job.queue_adapter = :sidekiq

if ENV["CLOUDFLARE_WORKER_HOST"].present?
config.action_controller.default_url_options = { host: ENV.fetch("CLOUDFLARE_WORKER_HOST") }
end
end
end
13 changes: 13 additions & 0 deletions db/migrate/20240607180302_create_cached_urls.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class CreateCachedUrls < ActiveRecord::Migration[7.1]
def change
create_table :cached_urls do |t|
t.text :url, null: false
t.string :tags, array: true, default: []
t.datetime :expires_at, null: false

t.timestamps
end

add_index :cached_urls, :url, unique: true
end
end
11 changes: 10 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions spec/models/cached_url_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
RSpec.describe CachedUrl do
describe ".expire_by_tags" do
before do

end

it "purges non expired urls at Cloudflare but deletes all of them from the DB" do
all = CachedUrl.create! url: "https://host.com/posts", tags: %w[section:posts posts:all], expires_at: 1.hour.from_now
id1 = CachedUrl.create! url: "https://host.com/posts/1", tags: %w[section:posts posts:1], expires_at: 10.minutes.from_now

# Already expired
CachedUrl.create! url: "https://host.com/posts/2", tags: %w[section:posts posts:2], expires_at: 5.minutes.ago

# Not requested
id3 = CachedUrl.create! url: "https://host.com/posts/3", tags: %w[section:posts posts:3], expires_at: 1.hour.from_now

purge_request = stub_request(:post, "https://api.cloudflare.com/client/v4/zones/zone-id/purge_cache")
.with(body: { files: [all.url, id1.url] }.to_json)

CachedUrl.expire_by_tags(%w[posts:all posts:1 posts:2])

expect(purge_request).to have_been_requested
expect(CachedUrl.pluck(:url)).to eq [id3.url]
end
end
end

0 comments on commit 711c9bd

Please sign in to comment.