etna 0.1.11

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6b95bd70a5a778deaaeb05408e95dd043dfd41914993986d770cf8c29f6ba54b
4
+ data.tar.gz: 9e2d68c4fcb362108ce3ebf203cf7e88fba5045e6b87164c13f7feb4a9811e17
5
+ SHA512:
6
+ metadata.gz: dfae596fc44742b0fd087cabd93ca8a2132f8501662cab18a2c46e86d33db9a84852ff061299bf5e694f70da1a62ed1b0057e6523799cec19b031fec3cd171ae
7
+ data.tar.gz: 2146b65d50ea6c9b610a762c3043748113a79c3f603fe0366f3519c81314f36983d0ea59d542e4a512ea01f8e0862a3e1f403884b755d077e75de53c5dae72a9
@@ -0,0 +1,90 @@
1
+ # a module to use for base application classes
2
+ # it is an included module rather than a class to spare
3
+ # us the burden of writing class Blah < Etna::Application
4
+ # whenever we want to use it as a container
5
+
6
+ require_relative './sign_service'
7
+ require 'singleton'
8
+
9
+ module Etna::Application
10
+ def self.included(other)
11
+ other.include Singleton
12
+ end
13
+
14
+ def self.find(klass)
15
+ Kernel.const_get(
16
+ klass.name.split('::').first
17
+ ).instance
18
+ end
19
+
20
+ def self.register(app)
21
+ @instance = app
22
+ end
23
+
24
+ def self.instance
25
+ @instance
26
+ end
27
+
28
+ def initialize
29
+ Etna::Application.register(self)
30
+ end
31
+
32
+ def configure(opts)
33
+ @config = opts
34
+ end
35
+
36
+ def setup_logger
37
+ @logger = Etna::Logger.new(
38
+ # The name of the log_file, required.
39
+ config(:log_file),
40
+ # Number of old copies of the log to keep.
41
+ config(:log_copies) || 5,
42
+ # How large the log can get before overturning.
43
+ config(:log_size) || 1048576
44
+ )
45
+ log_level = (config(:log_level) || 'warn').upcase.to_sym
46
+
47
+ @logger.level = Logger.const_defined?(log_level) ? Logger.const_get(log_level) : Logger::WARN
48
+ end
49
+
50
+ # the application logger is available globally
51
+ attr_reader :logger
52
+
53
+ def config(type)
54
+ @config[environment][type]
55
+ end
56
+
57
+ def sign
58
+ @sign ||= Etna::SignService.new(self)
59
+ end
60
+
61
+ def environment
62
+ (ENV["#{self.class.name.upcase}_ENV"] || :development).to_sym
63
+ end
64
+
65
+ def find_descendents(klass)
66
+ ObjectSpace.each_object(Class).select do |k|
67
+ k < klass
68
+ end
69
+ end
70
+
71
+ def run_command(config, cmd = :help, *args)
72
+ cmd = cmd.to_sym
73
+ if commands.key?(cmd)
74
+ commands[cmd].setup(config)
75
+ commands[cmd].execute(*args)
76
+ else
77
+ commands[:help].execute
78
+ end
79
+ end
80
+
81
+ def commands
82
+ @commands ||= Hash[
83
+ find_descendents(Etna::Command).map do |c|
84
+ cmd = c.new
85
+ [ cmd.name, cmd ]
86
+ end
87
+ ]
88
+ end
89
+ end
90
+
data/lib/etna/auth.rb ADDED
@@ -0,0 +1,128 @@
1
+ require_relative 'user'
2
+ require_relative 'hmac'
3
+
4
+ module Etna
5
+ class Auth
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ request = Rack::Request.new(env)
12
+
13
+ # There are three ways to authenticate.
14
+ # Either the route does not require auth,
15
+ # you have an hmac or you have a valid token.
16
+ # Both of these will not validate individual
17
+ # permissions; this is up to the controller
18
+ if [ approve_noauth(request), approve_hmac(request), approve_user(request) ].all?{|approved| !approved}
19
+ return fail_or_redirect(request)
20
+ end
21
+
22
+ @app.call(request.env)
23
+ end
24
+
25
+ private
26
+
27
+ def application
28
+ @application ||= Etna::Application.instance
29
+ end
30
+
31
+ def server
32
+ @server ||= application.class.const_get(:Server)
33
+ end
34
+
35
+ def params(request)
36
+ request.env['rack.request.params']
37
+ end
38
+
39
+ def auth(request, type)
40
+ (etna_param(request, :authorization, nil) || '')[/\A#{type.capitalize} (.*)\z/,1]
41
+ end
42
+
43
+ def self.etna_url_param(item)
44
+ :"X-Etna-#{item.to_s.split(/_/).map(&:capitalize).join('-')}"
45
+ end
46
+
47
+ def etna_param(request, item, fill='X_ETNA_')
48
+ # This comes either from header variable
49
+ # HTTP_X_SOME_NAME or parameter X-Etna-Some-Name
50
+ #
51
+ # We prefer the param so we can use the header elsewhere
52
+
53
+ params(request)[Etna::Auth.etna_url_param(item)] || request.env["HTTP_#{fill}#{item.upcase}"]
54
+ end
55
+
56
+ # If the application asks for a redirect for unauthorized users
57
+ def fail_or_redirect(request, msg = 'You are unauthorized')
58
+ return [ 401, { 'Content-Type' => 'text/html' }, [msg] ] unless application.config(:auth_redirect)
59
+
60
+ uri = URI(
61
+ application.config(:auth_redirect).chomp('/') + '/login'
62
+ )
63
+ uri.query = URI.encode_www_form(refer: request.url)
64
+ return [ 302, { 'Location' => uri.to_s }, [] ]
65
+ end
66
+
67
+ def approve_noauth(request)
68
+ route = server.find_route(request)
69
+
70
+ return route && route.noauth?
71
+ end
72
+
73
+ def approve_user(request)
74
+ token = request.cookies[application.config(:token_name)] || auth(request, :etna)
75
+
76
+ return false unless token
77
+
78
+ begin
79
+ payload, header = application.sign.jwt_decode(token)
80
+ return request.env['etna.user'] = Etna::User.new(payload.map{|k,v| [k.to_sym, v]}.to_h, token)
81
+ rescue
82
+ # bail out if anything goes wrong
83
+ return false
84
+ end
85
+ end
86
+
87
+ # Names and order of the fields to be signed.
88
+ def approve_hmac(request)
89
+ hmac_signature = etna_param(request, :signature)
90
+
91
+ return false unless hmac_signature
92
+
93
+ return false unless headers = etna_param(request, :headers)
94
+
95
+ headers = headers.split(/,/).map do |header|
96
+ [ header.to_sym, etna_param(request, header) ]
97
+ end.to_h
98
+
99
+ # Now expect the standard headers
100
+ hmac_params = {
101
+ method: request.request_method,
102
+ host: request.host,
103
+ path: request.path,
104
+
105
+ expiration: etna_param(request, :expiration),
106
+ id: etna_param(request, :id),
107
+ nonce: etna_param(request, :nonce),
108
+ headers: headers,
109
+ test_signature: hmac_signature
110
+ }
111
+
112
+ begin
113
+ hmac = Etna::Hmac.new(application, hmac_params)
114
+ rescue Exception => e
115
+ return false
116
+ end
117
+
118
+ request.env['etna.hmac'] = hmac
119
+
120
+ return nil unless hmac.valid?
121
+
122
+ # success! set the hmac header params as regular params
123
+ params(request).update(headers)
124
+
125
+ return true
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,144 @@
1
+ require 'net/http/persistent'
2
+ require 'net/http/post/multipart'
3
+ require 'singleton'
4
+
5
+ module Etna
6
+ class Client
7
+ def initialize(host, token)
8
+ @host = host.sub(%r!/$!,'')
9
+ @token = token
10
+
11
+ set_routes
12
+
13
+ define_route_helpers
14
+ end
15
+
16
+ attr_reader :routes
17
+
18
+ def route_path(route, params)
19
+ Etna::Route.path(route[:route], params)
20
+ end
21
+
22
+ private
23
+
24
+ def set_routes
25
+ response = options('/')
26
+ status_check!(response)
27
+ @routes = JSON.parse(response.body, symbolize_names: true)
28
+ end
29
+
30
+ def define_route_helpers
31
+ @routes.each do |route|
32
+ next unless route[:name]
33
+ self.define_singleton_method(route[:name]) do |params={}|
34
+
35
+ missing_params = (route[:params] - params.keys.map(&:to_s))
36
+ unless missing_params.empty?
37
+ raise ArgumentError, "Missing required #{missing_params.size > 1 ?
38
+ 'params' : 'param'} #{missing_params.join(', ')}"
39
+ end
40
+
41
+ response = send(route[:method].downcase, route_path(route, params), params)
42
+ if block_given?
43
+ yield response
44
+ else
45
+ if response.content_type == 'application/json'
46
+ return JSON.parse(response.body, symbolize_names: true)
47
+ else
48
+ return response.body
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ def persistent_connection
56
+ @http ||= begin
57
+ http = Net::HTTP::Persistent.new
58
+ http.read_timeout = 3600
59
+ http
60
+ end
61
+ end
62
+
63
+ def multipart_post(endpoint, content, &block)
64
+ uri = request_uri(endpoint)
65
+ multipart = Net::HTTP::Post::Multipart.new uri.path, content
66
+ multipart.add_field('Authorization', "Etna #{@token}")
67
+ request(uri, multipart, &block)
68
+ end
69
+
70
+ def post(endpoint, params={}, &block)
71
+ body_request(Net::HTTP::Post, endpoint, params, &block)
72
+ end
73
+
74
+ def get(endpoint, params={}, &block)
75
+ query_request(Net::HTTP::Get, endpoint, params, &block)
76
+ end
77
+
78
+ def options(endpoint, params={}, &block)
79
+ query_request(Net::HTTP::Options, endpoint, params, &block)
80
+ end
81
+
82
+ def delete(endpoint, params={}, &block)
83
+ body_request(Net::HTTP::Delete, endpoint, params, &block)
84
+ end
85
+
86
+ def body_request(type, endpoint, params={}, &block)
87
+ uri = request_uri(endpoint)
88
+ req = type.new(uri.path,request_params)
89
+ req.body = params.to_json
90
+ request(uri, req, &block)
91
+ end
92
+
93
+ def query_request(type, endpoint, params={}, &block)
94
+ uri = request_uri(endpoint)
95
+ uri.query = URI.encode_www_form(params)
96
+ req = type.new(uri.request_uri, request_params)
97
+ request(uri, req, &block)
98
+ end
99
+
100
+ def request_uri(endpoint)
101
+ URI("#{@host}#{endpoint}")
102
+ end
103
+
104
+ def request_params
105
+ {
106
+ 'Content-Type' => 'application/json',
107
+ 'Accept'=> 'application/json, text/*',
108
+ 'Authorization'=>"Etna #{@token}"
109
+ }
110
+ end
111
+
112
+ def status_check!(response)
113
+ status = response.code.to_i
114
+ if status >= 400
115
+ msg = response.content_type == 'application/json' ?
116
+ json_error(response.body) :
117
+ response.body
118
+ raise Etna::Error.new(msg, status)
119
+ end
120
+ end
121
+
122
+ def json_error(body)
123
+ msg = JSON.parse(body, symbolize_names: true)
124
+ if (msg.has_key?(:errors) && msg[:errors].is_a?(Array))
125
+ return msg[:errors].join(', ')
126
+ elsif msg.has_key?(:error)
127
+ return msg[:error]
128
+ end
129
+ end
130
+
131
+ def request(uri, data)
132
+ if block_given?
133
+ persistent_connection.request(uri, data) do |response|
134
+ status_check!(response)
135
+ yield response
136
+ end
137
+ else
138
+ response = persistent_connection.request(uri, data)
139
+ status_check!(response)
140
+ return response
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,26 @@
1
+ module Etna
2
+ class Command
3
+ class << self
4
+ def usage(desc)
5
+ define_method :usage do
6
+ " #{"%-30s" % name}#{desc}"
7
+ end
8
+ end
9
+ end
10
+
11
+ def name
12
+ self.class.name.snake_case.split(/::/).last.to_sym
13
+ end
14
+
15
+ # To be overridden during inheritance.
16
+ def execute
17
+ raise 'Command is not implemented'
18
+ end
19
+
20
+ # To be overridden during inheritance, to e.g. connect to a database.
21
+ # Should be called with super by inheriting method.
22
+ def setup(config)
23
+ Etna::Application.find(self.class).configure(config)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,111 @@
1
+ require 'erb'
2
+
3
+ module Etna
4
+ class Controller
5
+ def initialize(request, action = nil)
6
+ @request = request
7
+ @action = action
8
+ @response = Rack::Response.new
9
+ @params = @request.env['rack.request.params']
10
+ @errors = []
11
+ @server = @request.env['etna.server']
12
+ @logger = @request.env['etna.logger']
13
+ @user = @request.env['etna.user']
14
+ @request_id = @request.env['etna.request_id']
15
+ end
16
+
17
+ def log(line)
18
+ @logger.warn(request_msg(line))
19
+ end
20
+
21
+ def response(&block)
22
+ return instance_eval(&block) if block_given?
23
+
24
+ return send(@action) if @action
25
+
26
+ [501, {}, ['This controller is not implemented.']]
27
+ rescue Etna::Error => e
28
+ @logger.error(request_msg("Exiting with #{e.status}, #{e.message}"))
29
+ return failure(e.status, error: e.message)
30
+ rescue Exception => e
31
+ @logger.error(request_msg('Caught unspecified error'))
32
+ @logger.error(request_msg(e.message))
33
+ e.backtrace.each do |trace|
34
+ @logger.error(request_msg(trace))
35
+ end
36
+ return failure(500, error: 'Server error.')
37
+ end
38
+
39
+ def require_params(*params)
40
+ missing_params = params.reject{|p| @params.key?(p) }
41
+ raise Etna::BadRequest, "Missing param #{missing_params.join(', ')}" unless missing_params.empty?
42
+ end
43
+ alias_method :require_param, :require_params
44
+
45
+ def route_path(name, params={})
46
+ @server.class.route_path(@request, name, params)
47
+ end
48
+
49
+ def route_url(name, params={})
50
+ path = route_path(name,params)
51
+ return nil unless path
52
+ @request.scheme + '://' + @request.host + path
53
+ end
54
+
55
+ # methods for returning a view
56
+ VIEW_PATH = :VIEW_PATH
57
+
58
+ def view(name)
59
+ txt = File.read("#{self.class::VIEW_PATH}/#{name}.html")
60
+ @response['Content-Type'] = 'text/html'
61
+ @response.write(txt)
62
+ @response.finish
63
+ end
64
+
65
+ def erb_partial(name)
66
+ txt = File.read("#{self.class::VIEW_PATH}/#{name}.html.erb")
67
+ ERB.new(txt).result(binding)
68
+ end
69
+
70
+ def erb_view(name)
71
+ @response['Content-Type'] = 'text/html'
72
+ @response.write(erb_partial(name))
73
+ @response.finish
74
+ end
75
+
76
+ private
77
+
78
+ def success(msg, content_type='text/plain')
79
+ @response['Content-Type'] = content_type
80
+ @response.write(msg)
81
+ @response.finish
82
+ end
83
+
84
+ def success_json(params)
85
+ success(params.to_json, 'application/json')
86
+ end
87
+
88
+ def failure(status, msg)
89
+ @response['Content-Type'] = 'application/json'
90
+ @response.status = status
91
+ @response.write(msg.to_json)
92
+ @response.finish
93
+ end
94
+
95
+ def success?
96
+ @errors.empty?
97
+ end
98
+
99
+ def error(msg)
100
+ if msg.is_a?(Array)
101
+ @errors.concat(msg)
102
+ else
103
+ @errors.push(msg)
104
+ end
105
+ end
106
+
107
+ def request_msg(msg)
108
+ "#{@request_id} #{msg}"
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,71 @@
1
+ module Etna
2
+ class CrossOrigin
3
+ def initialize(app)
4
+ @app = app
5
+ end
6
+
7
+ def call(env)
8
+ request = Rack::Request.new(env)
9
+
10
+ # Don't filter unless this is a CORS request
11
+ return actual_response(request) unless has_header?(request, :origin)
12
+
13
+ # OPTIONS requests are a 'preflight' request
14
+ if request.request_method == 'OPTIONS'
15
+ return preflight_response(request)
16
+ end
17
+
18
+ # The actual request following a preflight should
19
+ # set some extra headers
20
+ return postflight_response(request)
21
+ end
22
+
23
+ private
24
+
25
+ def subdomain(host)
26
+ host.split(/\./)[-2..-1]
27
+ end
28
+
29
+ def origin_allowed?(request)
30
+ subdomain(URI.parse(header(request, :origin)).host) == subdomain(request.host)
31
+ end
32
+
33
+ def actual_response(request)
34
+ @app.call(request.env)
35
+ end
36
+
37
+ def postflight_response(request)
38
+ status, headers, body = actual_response(request)
39
+
40
+ if origin_allowed?(request)
41
+ headers.update(
42
+ 'Access-Control-Allow-Origin' => header(request, :origin),
43
+ 'Access-Control-Allow-Credentials' => 'true'
44
+ )
45
+ end
46
+
47
+ return [ status, headers, body ]
48
+ end
49
+
50
+ def preflight_response(request)
51
+ [
52
+ 200,
53
+ origin_allowed?(request) ? {
54
+ 'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
55
+ 'Access-Control-Allow-Headers' => header(request, :access_control_request_headers),
56
+ 'Access-Control-Allow-Origin' => header(request, :origin),
57
+ 'Access-Control-Allow-Credentials' => 'true'
58
+ } : {},
59
+ ['']
60
+ ]
61
+ end
62
+
63
+ def header(request, name)
64
+ request.get_header("HTTP_#{name.to_s.upcase}")
65
+ end
66
+
67
+ def has_header?(request, name)
68
+ request.has_header?("HTTP_#{name.to_s.upcase}")
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,31 @@
1
+ module Etna
2
+ class DescribeRoutes
3
+ def initialize(app)
4
+ @app = app
5
+ end
6
+
7
+ def call(env)
8
+ request = Rack::Request.new(env)
9
+
10
+ return @app.call(env) unless request.request_method == 'OPTIONS' && request.path == '/'
11
+
12
+ return [ 200, { 'Content-Type' => 'application/json' }, [ route_json ] ]
13
+ end
14
+
15
+ private
16
+
17
+ def route_json
18
+ server.routes.map do |route|
19
+ route.to_hash
20
+ end.to_json
21
+ end
22
+
23
+ def application
24
+ @application ||= Etna::Application.instance
25
+ end
26
+
27
+ def server
28
+ @server ||= application.class.const_get(:Server)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,37 @@
1
+ require 'logger'
2
+
3
+ module Etna
4
+ class Error < StandardError
5
+ attr_reader :level, :status
6
+ def initialize(msg = 'The application had an error', status=500)
7
+ super(msg)
8
+ @level = Logger::WARN
9
+ @status = status
10
+ end
11
+ end
12
+
13
+ class Forbidden < Etna::Error
14
+ def initialize(msg = 'Action not permitted', status = 403)
15
+ super
16
+ end
17
+ end
18
+
19
+ class Unauthorized < Etna::Error
20
+ def initialize(msg = 'Unauthorized request', status = 401)
21
+ super
22
+ end
23
+ end
24
+
25
+ class BadRequest < Etna::Error
26
+ def initialize(msg = 'Client error', status = 422)
27
+ super
28
+ end
29
+ end
30
+
31
+ class ServerError < Etna::Error
32
+ def initialize(msg = 'Server error', status = 500)
33
+ super
34
+ @level = Logger::ERROR
35
+ end
36
+ end
37
+ end
data/lib/etna/ext.rb ADDED
@@ -0,0 +1,13 @@
1
+ class String
2
+ def snake_case
3
+ return downcase if match(/\A[A-Z]+\z/)
4
+ gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').
5
+ gsub(/([a-z])([A-Z])/, '\1_\2').
6
+ downcase
7
+ end
8
+
9
+ def camel_case
10
+ return self if self !~ /_/ && self =~ /[A-Z]+.*/
11
+ split('_').map{|e| e.capitalize}.join
12
+ end
13
+ end