From 31799fc5afabfbfd9c5cdce21a3fad273da5f135 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 20:18:31 +0200 Subject: [PATCH] feat: extend conditional_get with Prism and ConditionalGet --- lib/prism/conditional_get.rb | 75 +++++++++++++++++++++++++++++++++ lib/prism/controller.rb | 80 ++++++++++++++++++++++++++++++++---- 2 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 lib/prism/conditional_get.rb diff --git a/lib/prism/conditional_get.rb b/lib/prism/conditional_get.rb new file mode 100644 index 0000000..641d9a6 --- /dev/null +++ b/lib/prism/conditional_get.rb @@ -0,0 +1,75 @@ +module Prism + # HTTP conditional GET support (ETag / Last-Modified), mixed into + # Controller alongside Renderable. Requires the host to provide + # `headers`, `request` and `halt` (all supplied by Renderable). + module ConditionalGet + # Sets ETag/Last-Modified and halts with 304 if the client's cached + # copy is still fresh. Returns nil (via halt) when it short-circuits. + def fresh_when(etag: nil, last_modified: nil, weak: true) + set_conditional_headers(etag: etag, last_modified: last_modified, weak: weak) + halt(412, '') if precondition_failed? + halt(304, '') if request_fresh? + end + + # Sets ETag/Last-Modified and returns whether the response is stale + # (i.e. whether the caller should still render). Halts with 304 itself + # when the client's copy is fresh, so callers can write + # `render(...) if stale?(etag: post)`. + def stale?(etag: nil, last_modified: nil, weak: true) + set_conditional_headers(etag: etag, last_modified: last_modified, weak: weak) + halt(412, '') if precondition_failed? + fresh = request_fresh? + halt(304, '') if fresh + !fresh + end + + private + + # If-Match/If-Unmodified-Since guard writes against the lost-update + # problem: unlike If-None-Match/If-Modified-Since (which ask "may I + # skip re-sending this?"), these ask "does the client's copy still + # match what's on the server?" and abort the request with 412 when it + # does not, regardless of HTTP method. + def precondition_failed? + match = request.headers['If-Match'] + unmodified_since = request.headers['If-Unmodified-Since'] + return false if match.nil? && unmodified_since.nil? + + match_ok = match.nil? || etag_matches?(match) + unmodified_ok = unmodified_since.nil? || not_modified_since?(unmodified_since) + !(match_ok && unmodified_ok) + end + + def set_conditional_headers(etag:, last_modified:, weak:) + if etag + digest = Digest::SHA1.hexdigest(etag.to_s) + headers['ETag'] = weak ? %(W/"#{digest}") : %("#{digest}") + end + headers['Last-Modified'] = last_modified.utc.httpdate if last_modified + end + + def request_fresh? + none_match = request.headers['If-None-Match'] + modified_since = request.headers['If-Modified-Since'] + return false if none_match.nil? && modified_since.nil? + + etag_fresh = none_match.nil? || etag_matches?(none_match) + time_fresh = modified_since.nil? || not_modified_since?(modified_since) + etag_fresh && time_fresh + end + + def etag_matches?(header_value) + current = headers['ETag'] + return false unless current + header_value.split(',').map(&:strip).any? { |candidate| candidate == '*' || candidate == current } + end + + def not_modified_since?(header_value) + current = headers['Last-Modified'] + return false unless current + Time.httpdate(header_value) >= Time.httpdate(current) + rescue ArgumentError + false + end + end +end diff --git a/lib/prism/controller.rb b/lib/prism/controller.rb index 60a8808..80a7965 100644 --- a/lib/prism/controller.rb +++ b/lib/prism/controller.rb @@ -1,22 +1,48 @@ module Prism class Controller include Renderable + include ConditionalGet class << self + # Combines this controller's own filters with everything declared on + # its ancestors, ancestors first, so a shared ApplicationController can + # declare `protect_from_forgery`/`before_action :require_authentication` + # once and have every subclass run it - the same way `layout`/ + # `views_root` already fall back to the superclass. def before_actions - @before_actions ||= [] + own = @before_actions ||= [] + superclass.respond_to?(:before_actions) ? superclass.before_actions + own : own end def after_actions - @after_actions ||= [] + own = @after_actions ||= [] + superclass.respond_to?(:after_actions) ? superclass.after_actions + own : own + end + + # Own handlers first (most recently declared first, like Rails' + # reverse-declaration-order matching), then the superclass's - so an + # ApplicationController-wide `rescue_from StandardError` never shadows + # a more specific handler a subclass declares for itself. + def rescue_handlers + own = (@rescue_handlers ||= []).reverse + superclass.respond_to?(:rescue_handlers) ? own + superclass.rescue_handlers : own + end + + def rescue_from(*exception_classes, with: nil, &block) + raise ArgumentError, 'rescue_from needs with: or a block' unless with || block + exception_classes.each { |klass| (@rescue_handlers ||= []) << { klass: klass, with: with, block: block } } end def before_action(*names, only: nil, except: nil, &block) - register_filter(before_actions, names, only, except, block) + register_filter(@before_actions ||= [], names, only, except, block) end def after_action(*names, only: nil, except: nil, &block) - register_filter(after_actions, names, only, except, block) + register_filter(@after_actions ||= [], names, only, except, block) + end + + def protect_from_forgery(only: nil, except: nil) + before_action(:verify_authenticity_token!, only: only, except: except) end def layout(name = :missing) @@ -29,8 +55,11 @@ module Prism @views_root || superclass_views_root || 'app/views' end + # "posts" for PostsController, "admin/posts" for Admin::PostsController + # (keeps the full module path so namespaced views live under + # app/views/admin/posts/, matching Rails' convention). def controller_path - Inflector.underscore(name.to_s.split('::').last.sub(/Controller\z/, '')) + Inflector.underscore(name.to_s.sub(/Controller\z/, '')) end private @@ -61,8 +90,12 @@ module Prism def process catch(:halt) do - run_before_actions - finalize_body(send(@action_name)) + begin + run_before_actions + finalize_body(send(@action_name)) + rescue StandardError => e + handle_exception(e) + end end catch(:halt) { run_after_actions } finalize_flash @@ -82,9 +115,40 @@ module Prism view_resolver.render_collection(partial_path(name), collection, locals: locals, as: as) end + # Rails-style content negotiation: `respond_to { |f| f.html { render }; f.json { json(x) } }`. + # Renders 406 when the negotiated format (see Request#format) has no matching block. + def respond_to + responder = Responder.new + yield responder + format = request.format + responder.respond(format) + rescue Responder::UnknownFormat + halt(406, "Not Acceptable: #{format}") + end + private + UnsafeMethods = %w[POST PUT PATCH DELETE].freeze + + def verify_authenticity_token! + return unless UnsafeMethods.include?(@request.request_method) + token = params['authenticity_token'] || @request.headers['X-Csrf-Token'] + halt(422, 'invalid authenticity token') unless csrf.valid_token?(token) + end + + # Finds the most specific registered `rescue_from` handler (`is_a?` + # against the raised exception) and runs it - the handler is expected + # to finish the response itself (`halt`/`render`/`redirect`), the same + # contract Rails' rescue_from has. Re-raises when nothing matches, so + # an unhandled exception still reaches the application-level fallback. + def handle_exception(exception) + handler = self.class.rescue_handlers.find { |candidate| exception.is_a?(candidate[:klass]) } + raise exception unless handler + handler[:block] ? instance_exec(exception, &handler[:block]) : send(handler[:with], exception) + end + def finalize_body(result) + return if @response.streaming? return unless @response.body.empty? @response.body = [result.to_s] if result.is_a?(String) end @@ -94,7 +158,7 @@ module Prism end def view_resolver - @view_resolver ||= View.new(self.class.views_root) + @view_resolver ||= View.new(self.class.views_root, helpers: self) end def run_before_actions