From bb80e7d3c449c448fe384424e029f9760cda5e7c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 6 Sep 2026 20:23:12 +0200 Subject: [PATCH] feat: add Prism, Cors and HttpRange --- lib/prism/cors.rb | 103 ++++++++++++++++++++++++++++++++++++++++ lib/prism/http_range.rb | 58 ++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 lib/prism/cors.rb create mode 100644 lib/prism/http_range.rb diff --git a/lib/prism/cors.rb b/lib/prism/cors.rb new file mode 100644 index 0000000..eaf6fbf --- /dev/null +++ b/lib/prism/cors.rb @@ -0,0 +1,103 @@ +module Prism + # Rack::Cors-lite: a small registry of cross-origin rules, matched by + # allowed origin then by resource path pattern. Prism::Middleware::Cors + # reads this registry on every request; it holds no per-request state. + module Cors + class Resource + attr_reader :pattern, :methods, :headers, :expose, :credentials, :max_age + + def initialize(pattern, methods: %w[GET], headers: :any, expose: [], credentials: false, max_age: nil) + @pattern = pattern + @methods = Array(methods).map { |m| m.to_s.upcase } + @headers = headers == :any ? :any : Array(headers).map(&:downcase) + @expose = Array(expose) + @credentials = credentials + @max_age = max_age + end + + def match?(path) + File.fnmatch(pattern, path) + end + + def allows_method?(method) + methods.include?(method.to_s.upcase) + end + + def allows_headers?(requested) + return true if headers == :any + requested_list = requested.to_s.split(',').map { |h| h.strip.downcase }.reject(&:empty?) + requested_list.all? { |h| headers.include?(h) } + end + end + + class Rule + def initialize + @origins = [] + @resources = [] + end + + def origins(*list) + @origins = list.flatten + end + + def resource(pattern, **options) + @resources << Resource.new(pattern, **options) + end + + def allows_origin?(origin) + return false if origin.nil? || origin.empty? + @origins.any? do |allowed| + case allowed + when '*' then true + when Regexp then allowed.match?(origin) + else allowed == origin + end + end + end + + def resource_for(path) + @resources.find { |resource| resource.match?(path) } + end + end + + class Configuration + attr_reader :rules + + def initialize + @rules = [] + end + + def allow(&block) + rule = Rule.new + rule.instance_eval(&block) + @rules << rule + rule + end + end + + class << self + def configuration + @configuration ||= Configuration.new + end + + def configure + yield configuration + end + + def reset! + @configuration = Configuration.new + end + + # Returns the [Rule, Resource] pair matching this origin/path, or nil. + def resource_for(origin, path) + configuration.rules.each do |rule| + next unless rule.allows_origin?(origin) + resource = rule.resource_for(path) + next unless resource + return [rule, resource] + end + nil + end + end + end +end diff --git a/lib/prism/http_range.rb b/lib/prism/http_range.rb new file mode 100644 index 0000000..2e9faed --- /dev/null +++ b/lib/prism/http_range.rb @@ -0,0 +1,58 @@ +module Prism + # RFC 7233 single-range parsing, shared by Middleware::Static and + # Middleware::Assets so both can serve `Range: bytes=...` requests (video + # seeking, resumable downloads, `curl -C -`) instead of always sending the + # whole file. Only a single byte-range-spec is honored - a request naming + # several comma-separated ranges falls back to a normal 200 with the full + # body, which RFC 7233 SS3.1 explicitly allows ("MAY ignore the Range + # header field"), rather than implementing multipart/byteranges for a case + # none of this framework's own clients ever send. + module HttpRange + Satisfiable = Struct.new(:start, :finish, :length, keyword_init: true) + + SPEC = /\A(\d*)-(\d*)\z/.freeze + + # Returns: + # nil - no Range header, or one this server chooses to ignore + # (multi-range) -> caller should send a normal 200. + # :unsatisfiable - a single range was given but it names no byte that + # exists in a file of `file_size` bytes -> caller + # should send 416. + # Satisfiable - a single, in-bounds range -> caller should send 206. + def self.parse(header, file_size) + return nil if header.nil? || !header.start_with?('bytes=') + + specs = header.delete_prefix('bytes=').split(',', -1).map(&:strip) + return nil if specs.size != 1 + + match = SPEC.match(specs.first) + return :unsatisfiable unless match + + start_s, end_s = match[1], match[2] + return :unsatisfiable if start_s.empty? && end_s.empty? + + if start_s.empty? + suffix_length = end_s.to_i + return :unsatisfiable if suffix_length <= 0 + start = [file_size - suffix_length, 0].max + finish = file_size - 1 + else + start = start_s.to_i + finish = end_s.empty? ? file_size - 1 : [end_s.to_i, file_size - 1].min + end + + return :unsatisfiable if file_size.zero? || start >= file_size || start > finish + + Satisfiable.new(start: start, finish: finish, length: finish - start + 1) + end + + # Reads only the requested slice, without loading the rest of the file + # into memory - the entire point of Range for a large video/asset file. + def self.read_slice(path, satisfiable) + File.open(path, 'rb') do |f| + f.seek(satisfiable.start) + f.read(satisfiable.length) || String.new + end + end + end +end