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.
- checksums.yaml +4 -4
- data/README.md +120 -7
- data/Rakefile +17 -1
- data/app/controllers/rails_wayback/assets_controller.rb +78 -0
- data/app/controllers/rails_wayback/pages_controller.rb +50 -9
- data/config/routes.rb +5 -0
- data/lib/generators/rails_wayback/install/install_generator.rb +5 -3
- data/lib/generators/rails_wayback/install/templates/initializer.rb.tt +18 -1
- data/lib/rails_wayback/asset_provenance.rb +41 -0
- data/lib/rails_wayback/bar_middleware.rb +235 -67
- data/lib/rails_wayback/bar_renderer.css +76 -0
- data/lib/rails_wayback/bar_renderer.html.erb +43 -0
- data/lib/rails_wayback/bar_renderer.js +361 -0
- data/lib/rails_wayback/bar_renderer.rb +70 -218
- data/lib/rails_wayback/bar_search.js +73 -0
- data/lib/rails_wayback/cache_inventory.rb +199 -0
- data/lib/rails_wayback/cli.rb +70 -3
- data/lib/rails_wayback/configuration.rb +13 -2
- data/lib/rails_wayback/controller_extensions.rb +71 -20
- data/lib/rails_wayback/doctor.rb +123 -0
- data/lib/rails_wayback/engine.rb +27 -19
- data/lib/rails_wayback/engine_mount.rb +15 -0
- data/lib/rails_wayback/git.rb +92 -8
- data/lib/rails_wayback/historical_asset_body.rb +41 -0
- data/lib/rails_wayback/historical_asset_helper.rb +68 -0
- data/lib/rails_wayback/historical_asset_resolver.rb +102 -0
- data/lib/rails_wayback/ref_policy.rb +64 -0
- data/lib/rails_wayback/render_context.rb +24 -0
- data/lib/rails_wayback/render_provenance.rb +84 -0
- data/lib/rails_wayback/render_tracker.rb +34 -0
- data/lib/rails_wayback/toggle.rb +10 -2
- data/lib/rails_wayback/version.rb +1 -1
- data/lib/rails_wayback/view_source.rb +171 -24
- data/lib/rails_wayback.rb +42 -6
- data/lib/tasks/rails_wayback.rake +17 -0
- data/rails-wayback.gemspec +2 -5
- 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
|
data/lib/rails_wayback/toggle.rb
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
|
|
5
5
|
module RailsWayback
|
|
6
|
-
#
|
|
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
|