|
| 1 | +#!/usr/bin/env ruby |
| 2 | +# frozen_string_literal: true |
| 3 | + |
| 4 | +require "digest" |
| 5 | +require "json" |
| 6 | +require "net/http" |
| 7 | +require "optparse" |
| 8 | +require "pathname" |
| 9 | +require "uri" |
| 10 | + |
| 11 | +options = { |
| 12 | + batch_size: 100, |
| 13 | + base_url: "https://cb341.dev" |
| 14 | +} |
| 15 | + |
| 16 | +OptionParser.new do |parser| |
| 17 | + parser.banner = "Usage: bin/purge_changed_cache OLD_BUILD_DIR NEW_BUILD_DIR [options]" |
| 18 | + |
| 19 | + parser.on("-u", "--base-url URL", "Public site URL, default: https://cb341.dev") do |value| |
| 20 | + options[:base_url] = value.delete_suffix("/") |
| 21 | + end |
| 22 | + |
| 23 | + parser.on("-n", "--dry-run", "Print URLs without calling Cloudflare") do |
| 24 | + options[:dry_run] = true |
| 25 | + end |
| 26 | +end.parse! |
| 27 | + |
| 28 | +old_dir, new_dir = ARGV.map { |path| Pathname(path).expand_path } |
| 29 | + |
| 30 | +abort "OLD_BUILD_DIR is required" unless old_dir |
| 31 | +abort "NEW_BUILD_DIR is required" unless new_dir |
| 32 | +abort "#{old_dir} does not exist" unless old_dir.directory? |
| 33 | +abort "#{new_dir} does not exist" unless new_dir.directory? |
| 34 | + |
| 35 | +def file_hashes(root) |
| 36 | + root.find.each_with_object({}) do |path, hashes| |
| 37 | + next unless path.file? |
| 38 | + |
| 39 | + relative_path = path.relative_path_from(root).to_s |
| 40 | + hashes[relative_path] = Digest::SHA256.file(path).hexdigest |
| 41 | + end |
| 42 | +end |
| 43 | + |
| 44 | +def public_urls(base_url, path) |
| 45 | + if path == "index.html" |
| 46 | + ["#{base_url}/", "#{base_url}/index.html"] |
| 47 | + elsif path.end_with?("/index.html") |
| 48 | + directory_url = "#{base_url}/#{path.delete_suffix('/index.html')}/" |
| 49 | + explicit_url = "#{base_url}/#{path}" |
| 50 | + [directory_url, explicit_url] |
| 51 | + else |
| 52 | + ["#{base_url}/#{path}"] |
| 53 | + end |
| 54 | +end |
| 55 | + |
| 56 | +old_hashes = file_hashes(old_dir) |
| 57 | +new_hashes = file_hashes(new_dir) |
| 58 | + |
| 59 | +changed_paths = (old_hashes.keys | new_hashes.keys).select do |path| |
| 60 | + old_hashes[path] != new_hashes[path] |
| 61 | +end |
| 62 | + |
| 63 | +urls = changed_paths.flat_map { |path| public_urls(options[:base_url], path) }.uniq.sort |
| 64 | + |
| 65 | +if urls.empty? |
| 66 | + puts "No changed cacheable URLs found." |
| 67 | + exit |
| 68 | +end |
| 69 | + |
| 70 | +puts "Purging #{urls.length} changed URL(s):" |
| 71 | +urls.each { |url| puts " #{url}" } |
| 72 | + |
| 73 | +exit if options[:dry_run] |
| 74 | + |
| 75 | +zone_id = ENV.fetch("CLOUDFLARE_ZONE_ID") do |
| 76 | + abort "CLOUDFLARE_ZONE_ID is required" |
| 77 | +end |
| 78 | + |
| 79 | +api_token = ENV.fetch("CLOUDFLARE_API_TOKEN") do |
| 80 | + abort "CLOUDFLARE_API_TOKEN is required" |
| 81 | +end |
| 82 | + |
| 83 | +endpoint = URI("https://api.cloudflare.com/client/v4/zones/#{zone_id}/purge_cache") |
| 84 | + |
| 85 | +urls.each_slice(options[:batch_size]) do |batch| |
| 86 | + response = Net::HTTP.start(endpoint.host, endpoint.port, use_ssl: true) do |http| |
| 87 | + request = Net::HTTP::Post.new(endpoint) |
| 88 | + request["Authorization"] = "Bearer #{api_token}" |
| 89 | + request["Content-Type"] = "application/json" |
| 90 | + request.body = JSON.generate(files: batch) |
| 91 | + http.request(request) |
| 92 | + end |
| 93 | + |
| 94 | + body = JSON.parse(response.body) |
| 95 | + next if response.is_a?(Net::HTTPSuccess) && body["success"] |
| 96 | + |
| 97 | + warn JSON.pretty_generate(body) |
| 98 | + abort "Cloudflare purge request failed with HTTP #{response.code}" |
| 99 | +end |
0 commit comments