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
data/lib/rails_wayback/cli.rb
CHANGED
|
@@ -10,7 +10,8 @@ module RailsWayback
|
|
|
10
10
|
# rails-wayback status # print current state
|
|
11
11
|
# rails-wayback clean # remove the ref cache under tmp/
|
|
12
12
|
class CLI
|
|
13
|
-
COMMANDS = %w[on off status clean help].freeze
|
|
13
|
+
COMMANDS = %w[on off status cache prune clean doctor help].freeze
|
|
14
|
+
DOCTOR_LABELS = { ok: "OK", warning: "WARN", error: "ERROR" }.freeze
|
|
14
15
|
|
|
15
16
|
def self.start(argv, stdout: $stdout, stderr: $stderr)
|
|
16
17
|
new(stdout: stdout, stderr: stderr).run(argv)
|
|
@@ -27,7 +28,10 @@ module RailsWayback
|
|
|
27
28
|
when "on" then cmd_on
|
|
28
29
|
when "off" then cmd_off
|
|
29
30
|
when "status" then cmd_status
|
|
31
|
+
when "cache" then cmd_cache
|
|
32
|
+
when "prune" then cmd_prune
|
|
30
33
|
when "clean" then cmd_clean
|
|
34
|
+
when "doctor" then cmd_doctor
|
|
31
35
|
when "help", "--help", "-h" then cmd_help
|
|
32
36
|
else
|
|
33
37
|
@stderr.puts "Unknown command: #{command}"
|
|
@@ -40,8 +44,11 @@ module RailsWayback
|
|
|
40
44
|
|
|
41
45
|
def cmd_on
|
|
42
46
|
RailsWayback.enable!
|
|
43
|
-
@stdout.puts "rails-wayback: enabled (mounted at /rails-wayback on the next request)"
|
|
47
|
+
@stdout.puts "rails-wayback: enabled for this server session (mounted at /rails-wayback on the next request)"
|
|
44
48
|
0
|
|
49
|
+
rescue RailsWayback::DisabledError => e
|
|
50
|
+
@stderr.puts "rails-wayback: cannot enable: #{e.message}"
|
|
51
|
+
1
|
|
45
52
|
end
|
|
46
53
|
|
|
47
54
|
def cmd_off
|
|
@@ -51,7 +58,12 @@ module RailsWayback
|
|
|
51
58
|
end
|
|
52
59
|
|
|
53
60
|
def cmd_status
|
|
54
|
-
|
|
61
|
+
if RailsWayback.environment_allowed?
|
|
62
|
+
@stdout.puts "rails-wayback: #{RailsWayback.toggle.status}"
|
|
63
|
+
else
|
|
64
|
+
environment = RailsWayback.current_environment.inspect
|
|
65
|
+
@stdout.puts "rails-wayback: unavailable (environment #{environment} is not allowed)"
|
|
66
|
+
end
|
|
55
67
|
0
|
|
56
68
|
end
|
|
57
69
|
|
|
@@ -61,6 +73,36 @@ module RailsWayback
|
|
|
61
73
|
0
|
|
62
74
|
end
|
|
63
75
|
|
|
76
|
+
def cmd_cache
|
|
77
|
+
snapshot = RailsWayback::ViewSource.new.cache_snapshot
|
|
78
|
+
@stdout.puts cache_summary(snapshot)
|
|
79
|
+
0
|
|
80
|
+
rescue RailsWayback::MaterializationError => e
|
|
81
|
+
@stderr.puts "rails-wayback: cannot inspect cache: #{e.message}"
|
|
82
|
+
1
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def cmd_prune
|
|
86
|
+
result = RailsWayback::ViewSource.new.prune!
|
|
87
|
+
@stdout.puts "rails-wayback: pruned #{result.removed.size} ref(s), " \
|
|
88
|
+
"freed #{format_bytes(result.removed_size_bytes)}"
|
|
89
|
+
@stdout.puts cache_summary(result.snapshot)
|
|
90
|
+
result.limits_satisfied? ? 0 : 1
|
|
91
|
+
rescue RailsWayback::MaterializationError => e
|
|
92
|
+
@stderr.puts "rails-wayback: cannot prune cache: #{e.message}"
|
|
93
|
+
1
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def cmd_doctor
|
|
97
|
+
result = RailsWayback::Doctor.new.call
|
|
98
|
+
@stdout.puts "rails-wayback doctor"
|
|
99
|
+
result.checks.each do |check|
|
|
100
|
+
@stdout.puts "[#{DOCTOR_LABELS.fetch(check.status)}] #{check.name}: #{check.message}"
|
|
101
|
+
end
|
|
102
|
+
@stdout.puts(result.ready? ? "Result: ready" : "Result: not ready")
|
|
103
|
+
result.exit_status
|
|
104
|
+
end
|
|
105
|
+
|
|
64
106
|
def cmd_help
|
|
65
107
|
@stdout.puts <<~HELP
|
|
66
108
|
Usage: rails-wayback <command>
|
|
@@ -69,10 +111,35 @@ module RailsWayback
|
|
|
69
111
|
on Enable the wayback UI in this Rails app
|
|
70
112
|
off Disable the wayback UI
|
|
71
113
|
status Print whether the UI is currently enabled
|
|
114
|
+
cache Print ref cache usage and configured limits
|
|
115
|
+
prune Remove least-recently-used refs until limits are satisfied
|
|
72
116
|
clean Remove the tmp/rails_wayback ref cache
|
|
117
|
+
doctor Check dependencies and host application readiness
|
|
73
118
|
help Show this help
|
|
74
119
|
HELP
|
|
75
120
|
0
|
|
76
121
|
end
|
|
122
|
+
|
|
123
|
+
def cache_summary(snapshot)
|
|
124
|
+
config = RailsWayback.configuration
|
|
125
|
+
count_limit = config.max_cached_refs.nil? ? "unlimited" : config.max_cached_refs
|
|
126
|
+
byte_limit = config.max_cache_bytes.nil? ? "unlimited" : format_bytes(config.max_cache_bytes)
|
|
127
|
+
"rails-wayback cache: #{snapshot.ref_count} ref(s), #{format_bytes(snapshot.size_bytes)}, " \
|
|
128
|
+
"#{snapshot.file_count} file(s); limits: #{count_limit} refs / #{byte_limit}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def format_bytes(value)
|
|
132
|
+
bytes = Integer(value)
|
|
133
|
+
return "#{bytes} B" if bytes < 1024
|
|
134
|
+
|
|
135
|
+
units = %w[KiB MiB GiB TiB]
|
|
136
|
+
amount = bytes / 1024.0
|
|
137
|
+
unit = units.shift
|
|
138
|
+
while amount >= 1024 && units.any?
|
|
139
|
+
amount /= 1024
|
|
140
|
+
unit = units.shift
|
|
141
|
+
end
|
|
142
|
+
format("%<amount>.1f %<unit>s", amount: amount, unit: unit)
|
|
143
|
+
end
|
|
77
144
|
end
|
|
78
145
|
end
|
|
@@ -10,14 +10,25 @@ module RailsWayback
|
|
|
10
10
|
DEFAULT_VIEW_PATHS = ["app/views", "app/components"].freeze
|
|
11
11
|
DEFAULT_ASSET_PATHS = ["app/assets", "public"].freeze
|
|
12
12
|
DEFAULT_MAX_COMMITS = 50
|
|
13
|
+
DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
|
14
|
+
DEFAULT_ALLOWED_ENVIRONMENTS = %w[development test].freeze
|
|
15
|
+
DEFAULT_TRUSTED_REF_PATTERNS = ["refs/heads/*"].freeze
|
|
16
|
+
DEFAULT_MAX_CACHED_REFS = 25
|
|
17
|
+
DEFAULT_MAX_CACHE_BYTES = 512 * 1024 * 1024
|
|
13
18
|
|
|
14
|
-
attr_accessor :view_paths, :asset_paths, :max_commits,
|
|
15
|
-
:
|
|
19
|
+
attr_accessor :view_paths, :asset_paths, :max_commits, :allowed_environments,
|
|
20
|
+
:max_response_bytes, :trusted_ref_patterns, :max_cached_refs,
|
|
21
|
+
:max_cache_bytes, :app_root, :cache_root, :toggle_file
|
|
16
22
|
|
|
17
23
|
def initialize
|
|
18
24
|
@view_paths = DEFAULT_VIEW_PATHS.dup
|
|
19
25
|
@asset_paths = DEFAULT_ASSET_PATHS.dup
|
|
20
26
|
@max_commits = DEFAULT_MAX_COMMITS
|
|
27
|
+
@max_response_bytes = DEFAULT_MAX_RESPONSE_BYTES
|
|
28
|
+
@allowed_environments = DEFAULT_ALLOWED_ENVIRONMENTS.dup
|
|
29
|
+
@trusted_ref_patterns = DEFAULT_TRUSTED_REF_PATTERNS.dup
|
|
30
|
+
@max_cached_refs = DEFAULT_MAX_CACHED_REFS
|
|
31
|
+
@max_cache_bytes = DEFAULT_MAX_CACHE_BYTES
|
|
21
32
|
@app_root = nil
|
|
22
33
|
@cache_root = nil
|
|
23
34
|
@toggle_file = nil
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RailsWayback
|
|
4
|
-
#
|
|
5
|
-
# engine's initializer. Does nothing on ordinary requests. When a
|
|
4
|
+
# Included in every `ActionController::Base` subclass. When a
|
|
6
5
|
# request carries `?_wayback_ref=<sha>` and the gem is enabled, it
|
|
7
|
-
#
|
|
8
|
-
# the current controller renders historical templates while
|
|
9
|
-
# its own instance variables intact.
|
|
6
|
+
# leases the materialised ref and prepends its directories to the view
|
|
7
|
+
# paths so the current controller renders historical templates while
|
|
8
|
+
# keeping its own instance variables intact.
|
|
10
9
|
module ControllerExtensions
|
|
11
10
|
extend ActiveSupport::Concern
|
|
12
11
|
|
|
@@ -15,8 +14,9 @@ module RailsWayback
|
|
|
15
14
|
RESPONSE_HEADER = "X-RailsWayback-Ref"
|
|
16
15
|
|
|
17
16
|
included do
|
|
18
|
-
|
|
17
|
+
around_action :__rails_wayback_with_views
|
|
19
18
|
helper_method :rails_wayback_active_ref
|
|
19
|
+
helper RailsWayback::HistoricalAssetHelper
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def rails_wayback_active_ref
|
|
@@ -25,17 +25,63 @@ module RailsWayback
|
|
|
25
25
|
|
|
26
26
|
private
|
|
27
27
|
|
|
28
|
-
def
|
|
29
|
-
return unless RailsWayback.enabled?
|
|
28
|
+
def __rails_wayback_with_views(&action)
|
|
29
|
+
return action.call unless RailsWayback.enabled?
|
|
30
30
|
|
|
31
31
|
ref = __rails_wayback_ref_from_request
|
|
32
|
-
return unless ref
|
|
32
|
+
return action.call unless ref
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
selection = authorize_wayback_ref(ref)
|
|
35
|
+
return action.call unless selection
|
|
35
36
|
|
|
37
|
+
request.env[RailsWayback::RefPolicy::ENV_KEY] = selection
|
|
38
|
+
if selection.rejected?
|
|
39
|
+
reject_wayback_ref(selection)
|
|
40
|
+
return action.call
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
render_with_wayback_views(selection, &action)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def authorize_wayback_ref(ref)
|
|
47
|
+
RailsWayback::RefPolicy.new.authorize(ref)
|
|
48
|
+
rescue RailsWayback::RefNotFoundError, RailsWayback::Git::GitError => e
|
|
49
|
+
handle_wayback_error(e)
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def render_with_wayback_views(selection, &action)
|
|
54
|
+
action_started = false
|
|
55
|
+
RailsWayback::ViewSource.new.with_view_roots(selection.sha) do |dirs, root|
|
|
56
|
+
with_wayback_materialization(root) do
|
|
57
|
+
prepend_wayback_views(selection, dirs)
|
|
58
|
+
action_started = true
|
|
59
|
+
action.call
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
rescue RailsWayback::RefNotFoundError, RailsWayback::Git::GitError => e
|
|
63
|
+
raise if action_started
|
|
64
|
+
|
|
65
|
+
handle_wayback_error(e)
|
|
66
|
+
action.call
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def with_wayback_materialization(root)
|
|
70
|
+
request.env[RailsWayback::HistoricalAssetHelper::MATERIALIZATION_ROOT_ENV] = root
|
|
71
|
+
yield
|
|
72
|
+
ensure
|
|
73
|
+
request.env.delete(RailsWayback::HistoricalAssetHelper::MATERIALIZATION_ROOT_ENV)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def handle_wayback_error(error)
|
|
77
|
+
log_warning("#{error.class}: #{error.message}")
|
|
78
|
+
write_wayback_header("error:#{error.class}")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def prepend_wayback_views(selection, dirs)
|
|
36
82
|
if dirs.empty?
|
|
37
|
-
log_warning("no view directories materialised for ref #{
|
|
38
|
-
|
|
83
|
+
log_warning("no view directories materialised for ref #{selection.sha}")
|
|
84
|
+
write_wayback_header("empty:#{selection.sha}")
|
|
39
85
|
return
|
|
40
86
|
end
|
|
41
87
|
|
|
@@ -44,14 +90,11 @@ module RailsWayback
|
|
|
44
90
|
# lookup context.
|
|
45
91
|
dirs.each { |dir| prepend_view_path(dir.to_s) }
|
|
46
92
|
|
|
47
|
-
@__rails_wayback_active_ref =
|
|
48
|
-
|
|
93
|
+
@__rails_wayback_active_ref = selection.sha
|
|
94
|
+
write_wayback_header(selection.sha)
|
|
49
95
|
|
|
50
|
-
log_info("prepended #{dirs.size} view path(s) from ref #{
|
|
96
|
+
log_info("prepended #{dirs.size} view path(s) from ref #{selection.sha}")
|
|
51
97
|
log_info("resolver order: #{resolver_summary}")
|
|
52
|
-
rescue RailsWayback::RefNotFoundError, RailsWayback::Git::GitError => e
|
|
53
|
-
log_warning("#{e.class}: #{e.message}")
|
|
54
|
-
set_header("error:#{e.class}")
|
|
55
98
|
end
|
|
56
99
|
|
|
57
100
|
# Params take precedence for one-off overrides (e.g. a shared URL);
|
|
@@ -65,6 +108,13 @@ module RailsWayback
|
|
|
65
108
|
cookies[WAYBACK_COOKIE].to_s.then { |v| v.empty? ? nil : v }
|
|
66
109
|
end
|
|
67
110
|
|
|
111
|
+
def reject_wayback_ref(selection)
|
|
112
|
+
response.delete_cookie(WAYBACK_COOKIE, path: "/")
|
|
113
|
+
response.delete_cookie("rails_wayback_branch", path: "/")
|
|
114
|
+
log_warning(selection.message)
|
|
115
|
+
write_wayback_header("rejected:#{selection.reason}")
|
|
116
|
+
end
|
|
117
|
+
|
|
68
118
|
def resolver_summary
|
|
69
119
|
lookup_context.view_paths.to_a.map { |r| resolver_path(r) }.join(" | ")
|
|
70
120
|
rescue StandardError
|
|
@@ -74,11 +124,12 @@ module RailsWayback
|
|
|
74
124
|
def resolver_path(resolver)
|
|
75
125
|
return resolver.to_path if resolver.respond_to?(:to_path)
|
|
76
126
|
return resolver.path if resolver.respond_to?(:path)
|
|
127
|
+
|
|
77
128
|
resolver.class.name
|
|
78
129
|
end
|
|
79
130
|
|
|
80
|
-
def
|
|
81
|
-
response
|
|
131
|
+
def write_wayback_header(value)
|
|
132
|
+
response&.set_header(RESPONSE_HEADER, value)
|
|
82
133
|
rescue StandardError
|
|
83
134
|
nil
|
|
84
135
|
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "tempfile"
|
|
7
|
+
|
|
8
|
+
module RailsWayback
|
|
9
|
+
# Performs explicit, non-destructive readiness checks for the host app.
|
|
10
|
+
class Doctor
|
|
11
|
+
Check = Struct.new(:name, :status, :message, keyword_init: true) do
|
|
12
|
+
def error?
|
|
13
|
+
status == :error
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
Result = Struct.new(:checks, keyword_init: true) do
|
|
18
|
+
def ready?
|
|
19
|
+
checks.none?(&:error?)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def exit_status
|
|
23
|
+
ready? ? 0 : 1
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(configuration: RailsWayback.configuration, git: nil,
|
|
28
|
+
environment: RailsWayback.current_environment, capture3: nil)
|
|
29
|
+
@configuration = configuration
|
|
30
|
+
@git = git || Git.new(root: configuration.app_root_path)
|
|
31
|
+
@environment = environment.to_s
|
|
32
|
+
@capture3 = capture3 || Open3.method(:capture3)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call
|
|
36
|
+
root_check = app_root_check
|
|
37
|
+
checks = [environment_check, root_check]
|
|
38
|
+
git_check = executable_check("Git", "git")
|
|
39
|
+
checks << git_check
|
|
40
|
+
checks << repository_check unless git_check.error? || root_check.error?
|
|
41
|
+
checks << executable_check("tar", "tar", version_optional: true)
|
|
42
|
+
checks << if root_check.error?
|
|
43
|
+
check("Cache directory", :error, "not checked because the application root is missing")
|
|
44
|
+
else
|
|
45
|
+
cache_check
|
|
46
|
+
end
|
|
47
|
+
checks << configured_paths_check
|
|
48
|
+
Result.new(checks: checks)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
attr_reader :configuration, :git, :environment, :capture3
|
|
54
|
+
|
|
55
|
+
def environment_check
|
|
56
|
+
allowed = Array(configuration.allowed_environments).map(&:to_s)
|
|
57
|
+
return check("Rails environment", :warning, "not detected; allowed: #{allowed.join(", ")}") if environment.empty?
|
|
58
|
+
return check("Rails environment", :ok, "#{environment} (allowed)") if allowed.include?(environment)
|
|
59
|
+
|
|
60
|
+
check("Rails environment", :error, "#{environment} is not allowed; allowed: #{allowed.join(", ")}")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def app_root_check
|
|
64
|
+
root = configuration.app_root_path
|
|
65
|
+
return check("Application root", :ok, root.to_s) if root.directory?
|
|
66
|
+
|
|
67
|
+
check("Application root", :error, "directory does not exist: #{root}")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def executable_check(label, executable, version_optional: false)
|
|
71
|
+
stdout, stderr, status = capture3.call(executable, "--version")
|
|
72
|
+
details = [stdout, stderr].map(&:strip).find { |value| !value.empty? }
|
|
73
|
+
return check(label, :ok, details || "available") if status.success?
|
|
74
|
+
return check(label, :warning, "available, but `#{executable} --version` is unsupported") if version_optional
|
|
75
|
+
|
|
76
|
+
check(label, :error, "#{executable} --version exited with status #{status.exitstatus}")
|
|
77
|
+
rescue Errno::ENOENT
|
|
78
|
+
check(label, :error, "required executable `#{executable}` was not found in PATH")
|
|
79
|
+
rescue SystemCallError => e
|
|
80
|
+
check(label, :error, "could not execute `#{executable}`: #{e.message}")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def repository_check
|
|
84
|
+
return check("Git repository", :ok, git.root.to_s) if git.repository?
|
|
85
|
+
|
|
86
|
+
check("Git repository", :error, "no Git work tree found at #{git.root}")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def cache_check
|
|
90
|
+
root = configuration.cache_root_path
|
|
91
|
+
FileUtils.mkdir_p(root)
|
|
92
|
+
Tempfile.create(".rails-wayback-doctor-", root) { |file| file.write("ok") }
|
|
93
|
+
check("Cache directory", :ok, "writable: #{root}")
|
|
94
|
+
rescue SystemCallError => e
|
|
95
|
+
check("Cache directory", :error, "not writable at #{root}: #{e.message}")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def configured_paths_check
|
|
99
|
+
paths = (Array(configuration.view_paths) + Array(configuration.asset_paths)).map(&:to_s).uniq
|
|
100
|
+
invalid = paths.select { |path| unsafe_relative_path?(path) }
|
|
101
|
+
return check("Configured paths", :error, "must be safe relative paths: #{invalid.join(", ")}") if invalid.any?
|
|
102
|
+
|
|
103
|
+
existing, missing = paths.partition { |path| configuration.app_root_path.join(path).directory? }
|
|
104
|
+
message = "#{existing.size}/#{paths.size} exist"
|
|
105
|
+
message = "#{message}; absent paths are skipped: #{missing.join(", ")}" if missing.any?
|
|
106
|
+
status = existing.empty? ? :warning : :ok
|
|
107
|
+
check("Configured paths", status, message)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def unsafe_relative_path?(value)
|
|
111
|
+
return true if value.empty?
|
|
112
|
+
|
|
113
|
+
path = Pathname.new(value)
|
|
114
|
+
path.absolute? || path.cleanpath.each_filename.first == ".."
|
|
115
|
+
rescue ArgumentError
|
|
116
|
+
true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def check(name, status, message)
|
|
120
|
+
Check.new(name: name, status: status, message: message)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/rails_wayback/engine.rb
CHANGED
|
@@ -9,6 +9,13 @@ module RailsWayback
|
|
|
9
9
|
class Engine < ::Rails::Engine
|
|
10
10
|
isolate_namespace RailsWayback
|
|
11
11
|
|
|
12
|
+
# Activation is scoped to a Rails server session. The marker still lets
|
|
13
|
+
# the standalone CLI toggle a running server, but a later server boot
|
|
14
|
+
# always starts with Wayback disabled.
|
|
15
|
+
server do
|
|
16
|
+
RailsWayback.toggle.reset!
|
|
17
|
+
end
|
|
18
|
+
|
|
12
19
|
initializer "rails_wayback.controller_extensions" do
|
|
13
20
|
ActiveSupport.on_load(:action_controller_base) do
|
|
14
21
|
include RailsWayback::ControllerExtensions
|
|
@@ -20,25 +27,26 @@ module RailsWayback
|
|
|
20
27
|
end
|
|
21
28
|
|
|
22
29
|
initializer "rails_wayback.render_instrumentation" do
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
30
|
+
RailsWayback::RENDER_EVENTS.each do |event_name|
|
|
31
|
+
ActiveSupport::Notifications.subscribe(event_name) do |*args|
|
|
32
|
+
next unless RailsWayback.enabled?
|
|
33
|
+
|
|
34
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
35
|
+
identifier = event.payload[:identifier].to_s
|
|
36
|
+
next if identifier.empty?
|
|
37
|
+
|
|
38
|
+
origin = RailsWayback::RenderProvenance.origin_for(identifier)
|
|
39
|
+
Rails.logger&.info(
|
|
40
|
+
"[rails-wayback] #{event.name} #{origin.to_s.upcase}: #{identifier}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Notifications outside the middleware's request scope are logged
|
|
44
|
+
# but deliberately excluded from toolbar provenance.
|
|
45
|
+
RailsWayback::RenderContext.current&.record(
|
|
46
|
+
event: event.name,
|
|
47
|
+
identifier: identifier
|
|
48
|
+
)
|
|
49
|
+
end
|
|
42
50
|
end
|
|
43
51
|
end
|
|
44
52
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsWayback
|
|
4
|
+
module EngineMount
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def path
|
|
8
|
+
RailsWayback::Engine.routes.default_url_options[:script_name] ||
|
|
9
|
+
RailsWayback::Engine.routes.find_script_name({}) ||
|
|
10
|
+
"/rails-wayback"
|
|
11
|
+
rescue StandardError
|
|
12
|
+
"/rails-wayback"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/rails_wayback/git.rb
CHANGED
|
@@ -9,17 +9,28 @@ module RailsWayback
|
|
|
9
9
|
# We shell out to git instead of pulling in a dependency to keep the
|
|
10
10
|
# gem light. All commands are scoped to the host app root and return
|
|
11
11
|
# plain Ruby data structures.
|
|
12
|
+
#
|
|
13
|
+
# NOTE: Maybe we should need to use a git library instead of shelling out to git.
|
|
14
|
+
# Only if this functionality is not enough for the gem on the future.
|
|
12
15
|
class Git
|
|
13
16
|
Commit = Struct.new(:sha, :short_sha, :subject, :author, :date, keyword_init: true)
|
|
17
|
+
Reference = Struct.new(:full_name, :name, :kind, :label, keyword_init: true)
|
|
18
|
+
REFERENCE_PREFIXES = {
|
|
19
|
+
"refs/heads/" => :branch,
|
|
20
|
+
"refs/remotes/" => :remote,
|
|
21
|
+
"refs/tags/" => :tag
|
|
22
|
+
}.freeze
|
|
23
|
+
REFERENCE_KIND_ORDER = { branch: 0, remote: 1, tag: 2 }.freeze
|
|
14
24
|
|
|
15
25
|
class GitError < StandardError; end
|
|
26
|
+
class ExecutableNotFoundError < GitError; end
|
|
16
27
|
|
|
17
28
|
def initialize(root: nil)
|
|
18
29
|
@root = Pathname.new(root || RailsWayback.configuration.app_root_path)
|
|
19
30
|
end
|
|
20
31
|
|
|
21
32
|
def repository?
|
|
22
|
-
|
|
33
|
+
run("rev-parse", "--is-inside-work-tree").strip == "true"
|
|
23
34
|
rescue GitError
|
|
24
35
|
false
|
|
25
36
|
end
|
|
@@ -37,6 +48,44 @@ module RailsWayback
|
|
|
37
48
|
output.split("\n").map(&:strip).reject(&:empty?)
|
|
38
49
|
end
|
|
39
50
|
|
|
51
|
+
# Lists refs that are already present in the local repository and match
|
|
52
|
+
# the same trust patterns used to authorize historical rendering. This
|
|
53
|
+
# never fetches or mutates Git state.
|
|
54
|
+
def references(patterns: RailsWayback.configuration.trusted_ref_patterns)
|
|
55
|
+
output = run(
|
|
56
|
+
"for-each-ref",
|
|
57
|
+
"--format=%(refname)%09%(symref)",
|
|
58
|
+
"refs/heads/",
|
|
59
|
+
"refs/remotes/",
|
|
60
|
+
"refs/tags/"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
discovered = output.split("\n").filter_map do |line|
|
|
64
|
+
full_name, symbolic_target = line.split("\t", 2)
|
|
65
|
+
next unless symbolic_target.to_s.empty?
|
|
66
|
+
next unless trusted_reference?(full_name, patterns)
|
|
67
|
+
|
|
68
|
+
build_reference(full_name)
|
|
69
|
+
end
|
|
70
|
+
discovered.sort_by { |reference| [REFERENCE_KIND_ORDER.fetch(reference.kind), reference.label] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Resolves either a canonical full ref name (preferred) or a legacy short
|
|
74
|
+
# branch name, but only from the configured trusted discovery set.
|
|
75
|
+
def reference(selector, patterns: RailsWayback.configuration.trusted_ref_patterns)
|
|
76
|
+
available = references(patterns: patterns)
|
|
77
|
+
input = selector.to_s
|
|
78
|
+
exact = available.find { |candidate| candidate.full_name == input }
|
|
79
|
+
return exact if exact
|
|
80
|
+
|
|
81
|
+
compatible = available.find do |candidate|
|
|
82
|
+
candidate.name == input || candidate.label == input
|
|
83
|
+
end
|
|
84
|
+
return compatible if compatible
|
|
85
|
+
|
|
86
|
+
raise GitError, "Git ref #{input.inspect} is not trusted or is unavailable locally"
|
|
87
|
+
end
|
|
88
|
+
|
|
40
89
|
def commits(branch, limit: RailsWayback.configuration.max_commits)
|
|
41
90
|
output = run(
|
|
42
91
|
"log",
|
|
@@ -53,6 +102,8 @@ module RailsWayback
|
|
|
53
102
|
|
|
54
103
|
def resolve_ref(ref)
|
|
55
104
|
run("rev-parse", "--verify", "#{ref}^{commit}").strip
|
|
105
|
+
rescue ExecutableNotFoundError
|
|
106
|
+
raise
|
|
56
107
|
rescue GitError
|
|
57
108
|
raise RefNotFoundError, "Unknown git ref: #{ref.inspect}"
|
|
58
109
|
end
|
|
@@ -61,6 +112,14 @@ module RailsWayback
|
|
|
61
112
|
run("show", "#{ref}:#{path}")
|
|
62
113
|
end
|
|
63
114
|
|
|
115
|
+
def tree?(ref, path)
|
|
116
|
+
run("cat-file", "-t", "#{ref}:#{path}").strip == "tree"
|
|
117
|
+
rescue ExecutableNotFoundError
|
|
118
|
+
raise
|
|
119
|
+
rescue GitError
|
|
120
|
+
false
|
|
121
|
+
end
|
|
122
|
+
|
|
64
123
|
# Returns the local branch names that contain `sha` in their history.
|
|
65
124
|
# Used by the bar to reflect the branch you are RENDERING with when
|
|
66
125
|
# you travel, instead of the branch that is checked out on disk
|
|
@@ -72,6 +131,11 @@ module RailsWayback
|
|
|
72
131
|
[]
|
|
73
132
|
end
|
|
74
133
|
|
|
134
|
+
def refs_containing(sha)
|
|
135
|
+
output = run("for-each-ref", "--contains=#{sha}", "--format=%(refname)")
|
|
136
|
+
output.split("\n").map(&:strip).reject(&:empty?)
|
|
137
|
+
end
|
|
138
|
+
|
|
75
139
|
# Same as `branches_containing`, but picks the most contextual one
|
|
76
140
|
# for display in the bar when the developer did NOT provide an
|
|
77
141
|
# explicit branch alongside the sha:
|
|
@@ -121,20 +185,40 @@ module RailsWayback
|
|
|
121
185
|
FileUtils.rm_f(target.join(".rails_wayback_index")) if target
|
|
122
186
|
end
|
|
123
187
|
|
|
124
|
-
|
|
125
|
-
@root
|
|
126
|
-
end
|
|
188
|
+
attr_reader :root
|
|
127
189
|
|
|
128
190
|
private
|
|
129
191
|
|
|
130
|
-
def
|
|
131
|
-
|
|
192
|
+
def trusted_reference?(full_name, patterns)
|
|
193
|
+
Array(patterns).map(&:to_s).any? { |pattern| File.fnmatch?(pattern, full_name) }
|
|
132
194
|
end
|
|
133
195
|
|
|
134
|
-
def
|
|
135
|
-
|
|
196
|
+
def build_reference(full_name)
|
|
197
|
+
prefix, kind = REFERENCE_PREFIXES.find { |candidate, _value| full_name.start_with?(candidate) }
|
|
198
|
+
return unless prefix
|
|
199
|
+
|
|
200
|
+
name = full_name.delete_prefix(prefix)
|
|
201
|
+
label = kind == :tag ? "tag:#{name}" : name
|
|
202
|
+
Reference.new(full_name: full_name, name: name, kind: kind, label: label)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def run(*)
|
|
206
|
+
run_with_env({}, *)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def run_with_env(env, *)
|
|
210
|
+
stdout, stderr, status = Open3.capture3(env, "git", "-C", @root.to_s, *)
|
|
136
211
|
raise GitError, stderr.strip unless status.success?
|
|
212
|
+
|
|
137
213
|
stdout
|
|
214
|
+
rescue Errno::ENOENT
|
|
215
|
+
raise ExecutableNotFoundError,
|
|
216
|
+
"Required executable `git` was not found in PATH. " \
|
|
217
|
+
"Run `bundle exec rails-wayback doctor` for diagnostics."
|
|
218
|
+
rescue SystemCallError => e
|
|
219
|
+
raise GitError,
|
|
220
|
+
"Could not execute required executable `git`: #{e.message}. " \
|
|
221
|
+
"Run `bundle exec rails-wayback doctor` for diagnostics."
|
|
138
222
|
end
|
|
139
223
|
end
|
|
140
224
|
end
|