rails-wayback 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -7
  3. data/Rakefile +17 -1
  4. data/app/controllers/rails_wayback/assets_controller.rb +78 -0
  5. data/app/controllers/rails_wayback/pages_controller.rb +50 -9
  6. data/config/routes.rb +5 -0
  7. data/lib/generators/rails_wayback/install/install_generator.rb +5 -3
  8. data/lib/generators/rails_wayback/install/templates/initializer.rb.tt +18 -1
  9. data/lib/rails_wayback/asset_provenance.rb +41 -0
  10. data/lib/rails_wayback/bar_middleware.rb +235 -67
  11. data/lib/rails_wayback/bar_renderer.css +76 -0
  12. data/lib/rails_wayback/bar_renderer.html.erb +43 -0
  13. data/lib/rails_wayback/bar_renderer.js +361 -0
  14. data/lib/rails_wayback/bar_renderer.rb +70 -218
  15. data/lib/rails_wayback/bar_search.js +73 -0
  16. data/lib/rails_wayback/cache_inventory.rb +199 -0
  17. data/lib/rails_wayback/cli.rb +70 -3
  18. data/lib/rails_wayback/configuration.rb +13 -2
  19. data/lib/rails_wayback/controller_extensions.rb +71 -20
  20. data/lib/rails_wayback/doctor.rb +123 -0
  21. data/lib/rails_wayback/engine.rb +27 -19
  22. data/lib/rails_wayback/engine_mount.rb +15 -0
  23. data/lib/rails_wayback/git.rb +92 -8
  24. data/lib/rails_wayback/historical_asset_body.rb +41 -0
  25. data/lib/rails_wayback/historical_asset_helper.rb +68 -0
  26. data/lib/rails_wayback/historical_asset_resolver.rb +102 -0
  27. data/lib/rails_wayback/ref_policy.rb +64 -0
  28. data/lib/rails_wayback/render_context.rb +24 -0
  29. data/lib/rails_wayback/render_provenance.rb +84 -0
  30. data/lib/rails_wayback/render_tracker.rb +34 -0
  31. data/lib/rails_wayback/toggle.rb +10 -2
  32. data/lib/rails_wayback/version.rb +1 -1
  33. data/lib/rails_wayback/view_source.rb +171 -24
  34. data/lib/rails_wayback.rb +42 -6
  35. data/lib/tasks/rails_wayback.rake +17 -0
  36. data/rails-wayback.gemspec +2 -5
  37. metadata +21 -33
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsWayback
4
+ class HistoricalAssetBody
5
+ DEFAULT_CHUNK_SIZE = 64 * 1024
6
+
7
+ def initialize(sha:, public_path:, view_source: RailsWayback::ViewSource.new,
8
+ resolver: RailsWayback::HistoricalAssetResolver.new,
9
+ chunk_size: DEFAULT_CHUNK_SIZE)
10
+ @sha = sha
11
+ @public_path = public_path
12
+ @view_source = view_source
13
+ @resolver = resolver
14
+ @chunk_size = chunk_size
15
+ end
16
+
17
+ def each
18
+ return enum_for(:each) unless block_given?
19
+
20
+ view_source.with_materialization(sha) do |root|
21
+ resolution = resolver.resolve_public_path(root: root, public_path: public_path)
22
+ unless resolution
23
+ raise RailsWayback::MaterializationError,
24
+ "Historical public asset #{public_path.inspect} is no longer available for #{sha}"
25
+ end
26
+
27
+ File.open(resolution.path, "rb") do |file|
28
+ while (chunk = file.read(chunk_size))
29
+ yield chunk
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ def close; end
36
+
37
+ private
38
+
39
+ attr_reader :sha, :public_path, :view_source, :resolver, :chunk_size
40
+ end
41
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsWayback
4
+ module HistoricalAssetHelper
5
+ MATERIALIZATION_ROOT_ENV = "rails_wayback.materialization_root"
6
+
7
+ def compute_asset_path(source, options = {})
8
+ resolve_historical_asset(source, options) || track_current_asset(source) { super }
9
+ end
10
+
11
+ def public_compute_asset_path(source, options = {})
12
+ resolve_historical_asset(source, options) || track_current_asset(source) { super }
13
+ end
14
+
15
+ def compute_asset_host(source = "", options = {})
16
+ if historical_asset_url?(source)
17
+ return request.base_url if options[:protocol] == :request
18
+
19
+ return nil
20
+ end
21
+
22
+ super
23
+ end
24
+
25
+ private
26
+
27
+ def resolve_historical_asset(source, options)
28
+ context = historical_asset_context
29
+ return unless context
30
+
31
+ sha, root = context
32
+ resolution = RailsWayback::HistoricalAssetResolver.new.resolve(
33
+ root: root,
34
+ source: source,
35
+ type: options[:type]
36
+ )
37
+ return unless resolution
38
+
39
+ RailsWayback::AssetProvenance.record(
40
+ origin: :historical,
41
+ identifier: "public/#{resolution.public_path}"
42
+ )
43
+ RailsWayback::HistoricalAssetResolver.url(
44
+ sha: sha,
45
+ public_path: resolution.public_path
46
+ )
47
+ end
48
+
49
+ def track_current_asset(source)
50
+ result = yield
51
+ RailsWayback::AssetProvenance.record(origin: :current, identifier: source.to_s) if historical_asset_context
52
+ result
53
+ end
54
+
55
+ def historical_asset_context
56
+ return unless respond_to?(:rails_wayback_active_ref)
57
+
58
+ sha = rails_wayback_active_ref.to_s
59
+ root = request.env[MATERIALIZATION_ROOT_ENV]
60
+ [sha, root] unless sha.empty? || !root
61
+ end
62
+
63
+ def historical_asset_url?(source)
64
+ sha = respond_to?(:rails_wayback_active_ref) ? rails_wayback_active_ref.to_s : ""
65
+ !sha.empty? && RailsWayback::HistoricalAssetResolver.historical_url?(source, sha: sha)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "rack/utils"
5
+
6
+ module RailsWayback
7
+ class HistoricalAssetResolver
8
+ TYPE_DIRECTORIES = {
9
+ audio: "audios",
10
+ font: "fonts",
11
+ image: "images",
12
+ javascript: "javascripts",
13
+ stylesheet: "stylesheets",
14
+ video: "videos"
15
+ }.freeze
16
+ ENCODED_TRAVERSAL = /%(?:2e|2f|5c)/i
17
+
18
+ Resolution = Struct.new(:path, :public_path, :size_bytes, keyword_init: true)
19
+
20
+ def resolve(root:, source:, type: nil)
21
+ candidates(source, type).each do |candidate|
22
+ resolution = resolve_public_path(root: root, public_path: candidate)
23
+ return resolution if resolution
24
+ end
25
+ nil
26
+ end
27
+
28
+ def resolve_public_path(root:, public_path:)
29
+ relative = normalize(public_path)
30
+ return unless relative
31
+
32
+ public_root = Pathname.new(root).join("public")
33
+ return unless safe_public_root?(public_root)
34
+
35
+ candidate = public_root.join(relative)
36
+ return unless candidate.file?
37
+
38
+ public_real = public_root.realpath
39
+ candidate_real = candidate.realpath
40
+ return unless under?(candidate_real, public_real)
41
+
42
+ Resolution.new(path: candidate_real, public_path: relative, size_bytes: candidate_real.size)
43
+ rescue SystemCallError
44
+ nil
45
+ end
46
+
47
+ def self.url(sha:, public_path:, mount: RailsWayback::EngineMount.path)
48
+ base = mount.to_s.sub(%r{/+\z}, "")
49
+ escaped = Rack::Utils.escape_path(public_path.to_s)
50
+ "#{base}/refs/#{sha}/assets/#{escaped}"
51
+ end
52
+
53
+ def self.historical_url?(source, sha:)
54
+ source.to_s.include?("/refs/#{sha}/assets/")
55
+ end
56
+
57
+ private
58
+
59
+ def candidates(source, type)
60
+ relative = normalize(source)
61
+ return [] unless relative
62
+
63
+ directory = TYPE_DIRECTORIES[type&.to_sym]
64
+ [relative, (File.join(directory, relative) if directory)].compact.uniq
65
+ end
66
+
67
+ def normalize(value)
68
+ raw = value.to_s
69
+ return unless safe_raw_path?(raw)
70
+
71
+ segments = raw.split("/", -1)
72
+ return if segments.any? { |segment| unsafe_segment?(segment) }
73
+
74
+ clean = Pathname.new(raw).cleanpath.to_s
75
+ clean unless clean == "." || clean.start_with?("../")
76
+ rescue ArgumentError
77
+ nil
78
+ end
79
+
80
+ def safe_raw_path?(raw)
81
+ !raw.empty? && !raw.start_with?("/") &&
82
+ !raw.include?("\0") && !raw.include?("\\") && !raw.match?(ENCODED_TRAVERSAL)
83
+ end
84
+
85
+ def unsafe_segment?(segment)
86
+ segment.empty? || [".", ".."].include?(segment)
87
+ end
88
+
89
+ def safe_public_root?(public_root)
90
+ stat = File.lstat(public_root)
91
+ stat.directory? && !stat.symlink?
92
+ rescue SystemCallError
93
+ false
94
+ end
95
+
96
+ def under?(path, root)
97
+ value = path.to_s
98
+ base = root.to_s
99
+ value.start_with?("#{base}#{File::SEPARATOR}")
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsWayback
4
+ # Authorizes immutable commits before historical files enter Action View.
5
+ class RefPolicy
6
+ ENV_KEY = "rails_wayback.ref_policy"
7
+ FULL_SHA = /\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/i
8
+
9
+ Result = Struct.new(:input, :sha, :trusted_refs, :reason, :message, keyword_init: true) do
10
+ def accepted?
11
+ reason.nil?
12
+ end
13
+
14
+ def rejected?
15
+ !accepted?
16
+ end
17
+ end
18
+
19
+ def initialize(configuration: RailsWayback.configuration, git: RailsWayback::Git.new)
20
+ @configuration = configuration
21
+ @git = git
22
+ end
23
+
24
+ def authorize(ref)
25
+ input = ref.to_s
26
+
27
+ unless valid_sha?(input)
28
+ return rejection(input, :invalid_format, "expected a full 40- or 64-character commit SHA")
29
+ end
30
+
31
+ sha = git.resolve_ref(input).downcase
32
+ trusted = trusted_refs_containing(sha)
33
+ if trusted.empty?
34
+ return rejection(input, :untrusted_ref, "commit is not reachable from a trusted Git ref", sha: sha)
35
+ end
36
+
37
+ Result.new(input: input, sha: sha, trusted_refs: trusted.freeze)
38
+ rescue RefNotFoundError
39
+ rejection(input, :unknown_ref, "commit does not exist in the local repository")
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :configuration, :git
45
+
46
+ def valid_sha?(ref)
47
+ ref.match?(FULL_SHA)
48
+ end
49
+
50
+ def trusted_refs_containing(sha)
51
+ patterns = Array(configuration.trusted_ref_patterns).map(&:to_s)
52
+ git.refs_containing(sha).select do |ref|
53
+ patterns.any? { |pattern| File.fnmatch?(pattern, ref) }
54
+ end.sort
55
+ end
56
+
57
+ def rejection(input, reason, detail, sha: nil)
58
+ label = (sha || input).inspect
59
+ label = "#{label.byteslice(0, 120)}..." if label.bytesize > 120
60
+ message = "Rejected travel ref #{label}: #{detail}."
61
+ Result.new(input: input, sha: sha, trusted_refs: [].freeze, reason: reason, message: message)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsWayback
4
+ # Exposes the request-owned render tracker to notification callbacks.
5
+ # Ruby fiber storage is inherited by child fibers and scoped separately
6
+ # for sibling fibers, which matches the lifecycle of async Rack requests.
7
+ module RenderContext
8
+ KEY = :rails_wayback_render_tracker
9
+
10
+ module_function
11
+
12
+ def current
13
+ Fiber[KEY]
14
+ end
15
+
16
+ def with(tracker)
17
+ previous = current
18
+ Fiber[KEY] = tracker
19
+ yield
20
+ ensure
21
+ Fiber[KEY] = previous
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module RailsWayback
6
+ # Classifies Action View render identifiers by filesystem origin and
7
+ # normalizes them back to paths relative to the host application. Both the
8
+ # engine logger and toolbar use this module so they cannot drift into
9
+ # applying different provenance rules.
10
+ module RenderProvenance
11
+ module_function
12
+
13
+ def origin_for(identifier, configuration: RailsWayback.configuration)
14
+ path = absolute_path(identifier)
15
+ return :other unless path
16
+
17
+ refs_root = File.expand_path(configuration.refs_cache_path.to_s)
18
+ return :historical if under?(path, refs_root)
19
+ return :current if current_view_roots(configuration).any? { |root| under?(path, root) }
20
+
21
+ :other
22
+ end
23
+
24
+ def paths_by_origin(entries, configuration: RailsWayback.configuration)
25
+ result = { historical: [], current: [] }
26
+
27
+ Array(entries).each do |entry|
28
+ identifier = identifier_from(entry)
29
+ origin = origin_for(identifier, configuration: configuration)
30
+ next unless result.key?(origin)
31
+
32
+ relative = relative_path(identifier, origin, configuration)
33
+ result[origin] << relative if relative
34
+ end
35
+
36
+ result.transform_values(&:uniq)
37
+ end
38
+
39
+ def under?(path, root)
40
+ path == root || path.start_with?("#{root}/")
41
+ end
42
+ private_class_method :under?
43
+
44
+ def absolute_path(identifier)
45
+ value = identifier.to_s
46
+ return nil if value.empty? || !Pathname.new(value).absolute?
47
+
48
+ File.expand_path(value)
49
+ end
50
+ private_class_method :absolute_path
51
+
52
+ def current_view_roots(configuration)
53
+ app_root = File.expand_path(configuration.app_root_path.to_s)
54
+ configuration.view_paths.map { |path| File.expand_path(path.to_s, app_root) }
55
+ end
56
+ private_class_method :current_view_roots
57
+
58
+ def identifier_from(entry)
59
+ case entry
60
+ when Hash
61
+ (entry[:identifier] || entry["identifier"]).to_s
62
+ else
63
+ entry.to_s
64
+ end
65
+ end
66
+ private_class_method :identifier_from
67
+
68
+ def relative_path(identifier, origin, configuration)
69
+ path = absolute_path(identifier)
70
+ return unless path
71
+
72
+ case origin
73
+ when :historical
74
+ refs_root = File.expand_path(configuration.refs_cache_path.to_s)
75
+ # Drop the refs root and the following SHA directory.
76
+ path.delete_prefix("#{refs_root}/").split("/", 2)[1]
77
+ when :current
78
+ app_root = File.expand_path(configuration.app_root_path.to_s)
79
+ path.delete_prefix("#{app_root}/") if under?(path, app_root)
80
+ end
81
+ end
82
+ private_class_method :relative_path
83
+ end
84
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsWayback
4
+ # Collects render events for one request. A tracker can be shared with
5
+ # child execution contexts while keeping mutation and closure atomic.
6
+ class RenderTracker
7
+ def initialize
8
+ @entries = []
9
+ @mutex = Mutex.new
10
+ @closed = false
11
+ end
12
+
13
+ def record(event:, identifier:)
14
+ @mutex.synchronize do
15
+ next false if @closed
16
+
17
+ @entries << { event: event.to_s, identifier: identifier.to_s }.freeze
18
+ true
19
+ end
20
+ end
21
+
22
+ def entries
23
+ @mutex.synchronize { @entries.dup }
24
+ end
25
+
26
+ def close
27
+ @mutex.synchronize { @closed = true }
28
+ end
29
+
30
+ def closed?
31
+ @mutex.synchronize { @closed }
32
+ end
33
+ end
34
+ end
@@ -3,11 +3,12 @@
3
3
  require "fileutils"
4
4
 
5
5
  module RailsWayback
6
- # Persists whether the engine should be mounted in the host app.
6
+ # Shares the current server session's state between the CLI and Rails.
7
7
  #
8
8
  # The flag is a plain file inside `tmp/rails_wayback/enabled` (ignored by
9
9
  # git). We keep it as a file so the CLI, rake tasks and running Rails
10
- # process all agree on the same state without booting extra services.
10
+ # process all agree on the same state without booting extra services. The
11
+ # engine clears stale state whenever a new Rails server session starts.
11
12
  class Toggle
12
13
  def initialize(configuration = RailsWayback.configuration)
13
14
  @configuration = configuration
@@ -30,6 +31,13 @@ module RailsWayback
30
31
  true
31
32
  end
32
33
 
34
+ # Start a new server session disabled. Unlike `disable!`, this does not
35
+ # touch routes: Rails is already loading them as part of server boot.
36
+ def reset!
37
+ FileUtils.rm_f(configuration.toggle_file_path)
38
+ true
39
+ end
40
+
33
41
  def status
34
42
  enabled? ? :on : :off
35
43
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsWayback
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end