etna 0.1.11

Sign up to get free protection for your applications and to get access to all the features.
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