turnout2024 3.0.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 +7 -0
- data/MIT-LICENSE +19 -0
- data/README.md +200 -0
- data/lib/rack/turnout.rb +28 -0
- data/lib/tasks/maintenance.rake +48 -0
- data/lib/turnout/configuration.rb +60 -0
- data/lib/turnout/engine.rb +16 -0
- data/lib/turnout/i18n/accept_language_parser.rb +109 -0
- data/lib/turnout/i18n/internationalization.rb +141 -0
- data/lib/turnout/maintenance_file.rb +119 -0
- data/lib/turnout/maintenance_page/base.rb +79 -0
- data/lib/turnout/maintenance_page/erb.rb +22 -0
- data/lib/turnout/maintenance_page/html.rb +21 -0
- data/lib/turnout/maintenance_page/json.rb +26 -0
- data/lib/turnout/maintenance_page.rb +24 -0
- data/lib/turnout/ordered_options.rb +98 -0
- data/lib/turnout/rake_tasks.rb +3 -0
- data/lib/turnout/request.rb +35 -0
- data/lib/turnout/version.rb +3 -0
- data/lib/turnout.rb +15 -0
- data/public/maintenance.html +69 -0
- data/public/maintenance.html.erb +69 -0
- data/public/maintenance.json +1 -0
- metadata +221 -0
@@ -0,0 +1,119 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'fileutils'
|
3
|
+
|
4
|
+
module Turnout
|
5
|
+
class MaintenanceFile
|
6
|
+
attr_reader :path
|
7
|
+
|
8
|
+
SETTINGS = [:reason, :allowed_paths, :allowed_ips, :response_code, :retry_after]
|
9
|
+
attr_reader(*SETTINGS)
|
10
|
+
|
11
|
+
def initialize(path)
|
12
|
+
@path = path
|
13
|
+
@reason = Turnout.config.default_reason
|
14
|
+
@allowed_paths = Turnout.config.default_allowed_paths
|
15
|
+
@allowed_ips = Turnout.config.default_allowed_ips
|
16
|
+
@response_code = Turnout.config.default_response_code
|
17
|
+
@retry_after = Turnout.config.default_retry_after
|
18
|
+
|
19
|
+
import_yaml if exists?
|
20
|
+
end
|
21
|
+
|
22
|
+
def exists?
|
23
|
+
File.exist? path
|
24
|
+
end
|
25
|
+
|
26
|
+
def to_h
|
27
|
+
SETTINGS.each_with_object({}) do |att, hash|
|
28
|
+
hash[att] = send(att)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def to_yaml(key_mapper = :to_s)
|
33
|
+
to_h.each_with_object({}) { |(key, val), hash|
|
34
|
+
hash[key.send(key_mapper)] = val
|
35
|
+
}.to_yaml
|
36
|
+
end
|
37
|
+
|
38
|
+
def write
|
39
|
+
FileUtils.mkdir_p(dir_path) unless Dir.exist? dir_path
|
40
|
+
|
41
|
+
File.open(path, 'w') do |file|
|
42
|
+
file.write to_yaml
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def delete
|
47
|
+
File.delete(path) if exists?
|
48
|
+
end
|
49
|
+
|
50
|
+
def import(hash)
|
51
|
+
SETTINGS.map(&:to_s).each do |att|
|
52
|
+
self.send(:"#{att}=", hash[att]) unless hash[att].nil?
|
53
|
+
end
|
54
|
+
|
55
|
+
true
|
56
|
+
end
|
57
|
+
alias :import_env_vars :import
|
58
|
+
|
59
|
+
# Find the first MaintenanceFile that exists
|
60
|
+
def self.find
|
61
|
+
path = named_paths.values.find { |p| File.exist? p }
|
62
|
+
self.new(path) if path
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.named(name)
|
66
|
+
path = named_paths[name.to_sym]
|
67
|
+
self.new(path) unless path.nil?
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.default
|
71
|
+
self.new(named_paths.values.first)
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def retry_after=(value)
|
77
|
+
@retry_after = value
|
78
|
+
end
|
79
|
+
|
80
|
+
def reason=(reason)
|
81
|
+
@reason = reason.to_s
|
82
|
+
end
|
83
|
+
|
84
|
+
# Splits strings on commas for easier importing of environment variables
|
85
|
+
def allowed_paths=(paths)
|
86
|
+
if paths.is_a? String
|
87
|
+
# Grab everything between commas that aren't escaped with a backslash
|
88
|
+
paths = paths.to_s.split(/(?<!\\),\ ?/).map do |path|
|
89
|
+
path.strip.gsub('\,', ',') # remove the escape characters
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
@allowed_paths = paths
|
94
|
+
end
|
95
|
+
|
96
|
+
# Splits strings on commas for easier importing of environment variables
|
97
|
+
def allowed_ips=(ips)
|
98
|
+
ips = ips.to_s.split(',') if ips.is_a? String
|
99
|
+
|
100
|
+
@allowed_ips = ips
|
101
|
+
end
|
102
|
+
|
103
|
+
def response_code=(code)
|
104
|
+
@response_code = code.to_i
|
105
|
+
end
|
106
|
+
|
107
|
+
def dir_path
|
108
|
+
File.dirname(path)
|
109
|
+
end
|
110
|
+
|
111
|
+
def import_yaml
|
112
|
+
import YAML::load(File.open(path)) || {}
|
113
|
+
end
|
114
|
+
|
115
|
+
def self.named_paths
|
116
|
+
Turnout.config.named_maintenance_file_paths
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
module Turnout
|
2
|
+
module MaintenancePage
|
3
|
+
class Base
|
4
|
+
attr_reader :reason
|
5
|
+
|
6
|
+
def initialize(reason = nil, options = {})
|
7
|
+
@options = options.is_a?(Hash) ? options : {}
|
8
|
+
@reason = reason
|
9
|
+
end
|
10
|
+
|
11
|
+
def rack_response(code = nil, retry_after = nil)
|
12
|
+
code ||= Turnout.config.default_response_code
|
13
|
+
[code, headers(retry_after), body]
|
14
|
+
end
|
15
|
+
|
16
|
+
# Override with an array of media type strings. i.e. text/html
|
17
|
+
def self.media_types
|
18
|
+
raise NotImplementedError, '.media_types must be overridden in subclasses'
|
19
|
+
end
|
20
|
+
def media_types() self.class.media_types end
|
21
|
+
|
22
|
+
# Override with a file extension value like 'html' or 'json'
|
23
|
+
def self.extension
|
24
|
+
raise NotImplementedError, '.extension must be overridden in subclasses'
|
25
|
+
end
|
26
|
+
def extension() self.class.extension end
|
27
|
+
|
28
|
+
def custom_path
|
29
|
+
Pathname.new(Turnout.config.maintenance_pages_path).join(filename)
|
30
|
+
end
|
31
|
+
|
32
|
+
protected
|
33
|
+
|
34
|
+
def self.inherited(subclass)
|
35
|
+
MaintenancePage.all << subclass
|
36
|
+
end
|
37
|
+
|
38
|
+
def headers(retry_after = nil)
|
39
|
+
headers = {'Content-Type' => media_types.first, 'Content-Length' => length}
|
40
|
+
# Include the Retry-After header unless it wasn't specified
|
41
|
+
headers['Retry-After'] = retry_after.to_s unless retry_after.nil?
|
42
|
+
headers
|
43
|
+
end
|
44
|
+
|
45
|
+
def length
|
46
|
+
content.bytesize.to_s
|
47
|
+
end
|
48
|
+
|
49
|
+
def body
|
50
|
+
[content]
|
51
|
+
end
|
52
|
+
|
53
|
+
def content
|
54
|
+
file_content.gsub(/{{\s?reason\s?}}/, reason)
|
55
|
+
end
|
56
|
+
|
57
|
+
def file_content
|
58
|
+
File.read(path)
|
59
|
+
end
|
60
|
+
|
61
|
+
def path
|
62
|
+
if File.exist? custom_path
|
63
|
+
custom_path
|
64
|
+
else
|
65
|
+
default_path
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def default_path
|
70
|
+
File.expand_path("../../../../public/#{filename}", __FILE__)
|
71
|
+
end
|
72
|
+
|
73
|
+
|
74
|
+
def filename
|
75
|
+
"maintenance.#{extension}"
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'erb'
|
2
|
+
require 'tilt'
|
3
|
+
require 'tilt/erb'
|
4
|
+
require_relative './html'
|
5
|
+
require_relative '../i18n/internationalization'
|
6
|
+
|
7
|
+
module Turnout
|
8
|
+
module MaintenancePage
|
9
|
+
class Erb < Turnout::MaintenancePage::HTML
|
10
|
+
|
11
|
+
def content
|
12
|
+
Turnout::Internationalization.initialize_i18n(@options[:env])
|
13
|
+
Tilt.new(File.expand_path(path)).render(self, {reason: reason}.merge(@options))
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.extension
|
17
|
+
'html.erb'
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require_relative './base'
|
2
|
+
module Turnout
|
3
|
+
module MaintenancePage
|
4
|
+
class HTML < Base
|
5
|
+
def reason
|
6
|
+
super.to_s.split("\n").map{|txt| "<p>#{txt}</p>" }.join("\n")
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.media_types
|
10
|
+
%w{
|
11
|
+
text/html
|
12
|
+
application/xhtml+xml
|
13
|
+
}
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.extension
|
17
|
+
'html'
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
module Turnout
|
4
|
+
module MaintenancePage
|
5
|
+
class JSON < Base
|
6
|
+
def reason
|
7
|
+
super.to_s.to_json
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.media_types
|
11
|
+
%w{
|
12
|
+
application/json
|
13
|
+
text/json
|
14
|
+
application/x-javascript
|
15
|
+
text/javascript
|
16
|
+
text/x-javascript
|
17
|
+
text/x-json
|
18
|
+
}
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.extension
|
22
|
+
'json'
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
module Turnout
|
3
|
+
module MaintenancePage
|
4
|
+
require 'rack/accept'
|
5
|
+
|
6
|
+
def self.all
|
7
|
+
@all ||= []
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.best_for(env)
|
11
|
+
request = Rack::Accept::Request.new(env)
|
12
|
+
|
13
|
+
all_types = all.map(&:media_types).flatten
|
14
|
+
best_type = request.best_media_type(all_types)
|
15
|
+
best = all.find { |page| page.media_types.include?(best_type) && File.exist?(page.new.custom_path) }
|
16
|
+
best || Turnout.config.default_maintenance_page
|
17
|
+
end
|
18
|
+
|
19
|
+
require 'turnout/maintenance_page/base'
|
20
|
+
require 'turnout/maintenance_page/erb'
|
21
|
+
require 'turnout/maintenance_page/html'
|
22
|
+
require 'turnout/maintenance_page/json'
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
module Turnout
|
2
|
+
class OrderedOptions < Hash
|
3
|
+
alias_method :_get, :[] # preserve the original #[] method
|
4
|
+
protected :_get # make it protected
|
5
|
+
|
6
|
+
def initialize(constructor = {}, &block)
|
7
|
+
if constructor.respond_to?(:to_hash)
|
8
|
+
super()
|
9
|
+
update(constructor, &block)
|
10
|
+
hash = constructor.to_hash
|
11
|
+
|
12
|
+
self.default = hash.default if hash.default
|
13
|
+
self.default_proc = hash.default_proc if hash.default_proc
|
14
|
+
else
|
15
|
+
super()
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def update(other_hash)
|
20
|
+
if other_hash.is_a? Hash
|
21
|
+
super(other_hash)
|
22
|
+
else
|
23
|
+
other_hash.to_hash.each_pair do |key, value|
|
24
|
+
if block_given?
|
25
|
+
value = yield(key, value)
|
26
|
+
end
|
27
|
+
self[key] = value
|
28
|
+
end
|
29
|
+
self
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def []=(key, value)
|
34
|
+
super(key.to_sym, value)
|
35
|
+
end
|
36
|
+
|
37
|
+
def [](key)
|
38
|
+
super(key.to_sym)
|
39
|
+
end
|
40
|
+
|
41
|
+
def method_missing(name, *args)
|
42
|
+
name_string = name.to_s
|
43
|
+
if name_string.chomp!('=')
|
44
|
+
self[name_string] = args.first
|
45
|
+
else
|
46
|
+
bangs = name_string.chomp!('!')
|
47
|
+
|
48
|
+
if bangs
|
49
|
+
value = fetch(name_string.to_sym)
|
50
|
+
raise(RuntimeError.new("#{name_string} is blank.")) if value.nil? || value.empty?
|
51
|
+
value
|
52
|
+
else
|
53
|
+
self[name_string]
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def except(*keys)
|
59
|
+
dup.except!(*keys)
|
60
|
+
end
|
61
|
+
|
62
|
+
def except!(*keys)
|
63
|
+
keys.each { |key| delete(key) }
|
64
|
+
self
|
65
|
+
end
|
66
|
+
|
67
|
+
|
68
|
+
def respond_to_missing?(name, include_private)
|
69
|
+
true
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
|
74
|
+
# +InheritableOptions+ provides a constructor to build an +OrderedOptions+
|
75
|
+
# hash inherited from another hash.
|
76
|
+
#
|
77
|
+
# Use this if you already have some hash and you want to create a new one based on it.
|
78
|
+
#
|
79
|
+
# h = ActiveSupport::InheritableOptions.new({ girl: 'Mary', boy: 'John' })
|
80
|
+
# h.girl # => 'Mary'
|
81
|
+
# h.boy # => 'John'
|
82
|
+
class InheritableOptions < Turnout::OrderedOptions
|
83
|
+
def initialize(parent = nil)
|
84
|
+
if parent.kind_of?(Turnout::OrderedOptions)
|
85
|
+
# use the faster _get when dealing with OrderedOptions
|
86
|
+
super(parent) {|key,value| parent._get(key) }
|
87
|
+
elsif parent
|
88
|
+
super(parent) { |key, value| parent[key] }
|
89
|
+
else
|
90
|
+
super(parent)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def inheritable_copy
|
95
|
+
self.class.new(self)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'ipaddr'
|
2
|
+
|
3
|
+
module Turnout
|
4
|
+
class Request
|
5
|
+
def initialize(env)
|
6
|
+
@rack_request = Rack::Request.new(env)
|
7
|
+
end
|
8
|
+
|
9
|
+
def allowed?(settings)
|
10
|
+
path_allowed?(settings.allowed_paths) || ip_allowed?(settings.allowed_ips)
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
attr_reader :rack_request
|
16
|
+
|
17
|
+
def path_allowed?(allowed_paths)
|
18
|
+
allowed_paths.any? do |allowed_path|
|
19
|
+
rack_request.path =~ Regexp.new(allowed_path)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def ip_allowed?(allowed_ips)
|
24
|
+
begin
|
25
|
+
ip = IPAddr.new(rack_request.ip.to_s)
|
26
|
+
rescue ArgumentError
|
27
|
+
return false
|
28
|
+
end
|
29
|
+
|
30
|
+
allowed_ips.any? do |allowed_ip|
|
31
|
+
IPAddr.new(allowed_ip).include? ip
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
data/lib/turnout.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
module Turnout
|
2
|
+
require 'turnout/configuration'
|
3
|
+
require 'turnout/maintenance_file'
|
4
|
+
require 'turnout/maintenance_page'
|
5
|
+
require 'turnout/request'
|
6
|
+
require 'turnout/engine' if defined? Rails
|
7
|
+
|
8
|
+
def self.configure
|
9
|
+
yield config
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.config
|
13
|
+
@config ||= Configuration.new
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<meta charset="UTF-8">
|
5
|
+
<title>Down for Maintenance</title>
|
6
|
+
|
7
|
+
<style type="text/css">
|
8
|
+
|
9
|
+
*{
|
10
|
+
font-family: Arial, Helvetica, sans-serif;
|
11
|
+
}
|
12
|
+
|
13
|
+
body{
|
14
|
+
margin: 0;
|
15
|
+
background-color: #fff;
|
16
|
+
}
|
17
|
+
|
18
|
+
#page{
|
19
|
+
position: relative;
|
20
|
+
width: 550px;
|
21
|
+
margin: 200px auto;
|
22
|
+
padding: 75px 0;
|
23
|
+
text-align: center;
|
24
|
+
background-color: #eaeaea;
|
25
|
+
border: solid 1px #ccc;
|
26
|
+
border-top: solid 10px #666;
|
27
|
+
-moz-box-shadow: inset 0 2px 10px #ccc;
|
28
|
+
-webkit-box-shadow: inset 0 2px 10px #ccc;
|
29
|
+
box-shadow: inset 0 2px 10px #ccc;
|
30
|
+
}
|
31
|
+
|
32
|
+
header, #body{
|
33
|
+
width: 400px;
|
34
|
+
margin: 0 auto;
|
35
|
+
}
|
36
|
+
|
37
|
+
h1{
|
38
|
+
margin: 0;
|
39
|
+
color: #CC3601;
|
40
|
+
font-size: 26pt;
|
41
|
+
border-bottom: solid 4px #666;
|
42
|
+
}
|
43
|
+
|
44
|
+
#reason{
|
45
|
+
margin: 10px 0;
|
46
|
+
color: #333;
|
47
|
+
}
|
48
|
+
|
49
|
+
</style>
|
50
|
+
|
51
|
+
</head>
|
52
|
+
<body>
|
53
|
+
|
54
|
+
<section id="page">
|
55
|
+
|
56
|
+
<header>
|
57
|
+
<h1>Down for Maintenance</h1>
|
58
|
+
</header>
|
59
|
+
|
60
|
+
<section id="body">
|
61
|
+
<div>
|
62
|
+
{{ reason }}
|
63
|
+
</div>
|
64
|
+
</section>
|
65
|
+
|
66
|
+
</section>
|
67
|
+
|
68
|
+
</body>
|
69
|
+
</html>
|
@@ -0,0 +1,69 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<meta charset="UTF-8">
|
5
|
+
<title>Down for Maintenance</title>
|
6
|
+
|
7
|
+
<style type="text/css">
|
8
|
+
|
9
|
+
*{
|
10
|
+
font-family: Arial, Helvetica, sans-serif;
|
11
|
+
}
|
12
|
+
|
13
|
+
body{
|
14
|
+
margin: 0;
|
15
|
+
background-color: #fff;
|
16
|
+
}
|
17
|
+
|
18
|
+
#page{
|
19
|
+
position: relative;
|
20
|
+
width: 550px;
|
21
|
+
margin: 200px auto;
|
22
|
+
padding: 75px 0;
|
23
|
+
text-align: center;
|
24
|
+
background-color: #eaeaea;
|
25
|
+
border: solid 1px #ccc;
|
26
|
+
border-top: solid 10px #666;
|
27
|
+
-moz-box-shadow: inset 0 2px 10px #ccc;
|
28
|
+
-webkit-box-shadow: inset 0 2px 10px #ccc;
|
29
|
+
box-shadow: inset 0 2px 10px #ccc;
|
30
|
+
}
|
31
|
+
|
32
|
+
header, #body{
|
33
|
+
width: 400px;
|
34
|
+
margin: 0 auto;
|
35
|
+
}
|
36
|
+
|
37
|
+
h1{
|
38
|
+
margin: 0;
|
39
|
+
color: #CC3601;
|
40
|
+
font-size: 26pt;
|
41
|
+
border-bottom: solid 4px #666;
|
42
|
+
}
|
43
|
+
|
44
|
+
#reason{
|
45
|
+
margin: 10px 0;
|
46
|
+
color: #333;
|
47
|
+
}
|
48
|
+
|
49
|
+
</style>
|
50
|
+
|
51
|
+
</head>
|
52
|
+
<body>
|
53
|
+
|
54
|
+
<section id="page">
|
55
|
+
|
56
|
+
<header>
|
57
|
+
<h1>Down for Maintenance</h1>
|
58
|
+
</header>
|
59
|
+
|
60
|
+
<section id="body">
|
61
|
+
<div>
|
62
|
+
<%= reason %>
|
63
|
+
</div>
|
64
|
+
</section>
|
65
|
+
|
66
|
+
</section>
|
67
|
+
|
68
|
+
</body>
|
69
|
+
</html>
|
@@ -0,0 +1 @@
|
|
1
|
+
{"error":"Service Unavailable","message":{{ reason }}}
|