authralia 0.0.1 → 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/authenticate/base.rb +6 -0
- data/lib/authenticate/resource.rb +27 -0
- data/lib/authenticated_session/acceptence.rb +24 -0
- data/lib/authenticated_session/base.rb +74 -0
- data/lib/authenticated_session/creation.rb +42 -0
- data/lib/authenticated_session/rejection.rb +16 -0
- data/lib/authenticated_session/removal.rb +36 -0
- data/lib/authenticated_session/validation.rb +95 -0
- data/lib/authenticated_session/wipeout.rb +15 -0
- data/lib/authralia.rb +5 -5
- data/lib/controllers/helpers.rb +112 -0
- data/lib/models/thydney_the_protector.rb +21 -0
- metadata +43 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 414a5e20e68e0a941cfee786236b8936b20c6c501abb1c0915eb88c0d2a75ca4
|
4
|
+
data.tar.gz: 4a88420c5c8a63f744c0aa8d5a88314fd59060ec73ae717105bf6423d40a078f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 19aaf48d110abd50ec78b293312c5e34ad80b23fa99af8c5c1017037398939395bc1f376af6f3113f7749e35dc90d2f7f438e71927f80bfeb07df0aaaebb27d1
|
7
|
+
data.tar.gz: a7f01ccea9d49569e61174d1d34c26885219ee1a91a296906da809dd17b6e67e419320594aa93f56d3109d84dc430a83f741fbcc1d8d25e7b5a46a921e008201
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Authralia
|
2
|
+
module Authenticate
|
3
|
+
class Resource < Base
|
4
|
+
def initialize(resource_class_name, auth_data)
|
5
|
+
@auth_data = auth_data
|
6
|
+
@resource_class = resource_class_name.constantize
|
7
|
+
end
|
8
|
+
|
9
|
+
def call
|
10
|
+
if @auth_data.class.name == @resource_class.to_s
|
11
|
+
response(status: SUCCESS, payload: @auth_data)
|
12
|
+
else
|
13
|
+
resource = @resource_class.protect_thcope(@auth_data)
|
14
|
+
|
15
|
+
if resource&.authenticate(@auth_data[:password])
|
16
|
+
response(status: SUCCESS, payload: resource)
|
17
|
+
else
|
18
|
+
response(
|
19
|
+
status: FAIL,
|
20
|
+
message: "Incorrect Login Data"
|
21
|
+
)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Authralia
|
2
|
+
module AuthenticatedSession
|
3
|
+
class Acceptence < Base
|
4
|
+
def initialize(session_identifier, guid)
|
5
|
+
@session_identifier = session_identifier
|
6
|
+
@guid = guid
|
7
|
+
end
|
8
|
+
|
9
|
+
def call
|
10
|
+
response(status: SUCCESS) if @session_identifier.blank?
|
11
|
+
|
12
|
+
update_sessions(@session_identifier) do |session|
|
13
|
+
if session[:guid] == @guid
|
14
|
+
session[:is_accepted] = true
|
15
|
+
else
|
16
|
+
session[:new_session_guids].delete(@guid)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
response(status: SUCCESS)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module Authralia
|
2
|
+
module AuthenticatedSession
|
3
|
+
class Base < SerpisObjek::Base
|
4
|
+
protected
|
5
|
+
|
6
|
+
def extract_resource_identifier(session_identifier)
|
7
|
+
session_identifier.split('#').first(2).join('#')
|
8
|
+
end
|
9
|
+
|
10
|
+
def extract_guid(session_identifier)
|
11
|
+
session_identifier.split('#').last
|
12
|
+
end
|
13
|
+
|
14
|
+
def build_resource_identifier(resource)
|
15
|
+
"#{resource.class.name}##{resource.id}"
|
16
|
+
end
|
17
|
+
|
18
|
+
def filter_sessions(value)
|
19
|
+
return [] unless value.is_a? Array
|
20
|
+
|
21
|
+
value.select do |session|
|
22
|
+
session.is_a?(Hash)
|
23
|
+
end.map do |session|
|
24
|
+
session.symbolize_keys
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def add_session(resource, session)
|
29
|
+
handler = get_session_handler(resource)
|
30
|
+
|
31
|
+
handler.value ||= []
|
32
|
+
handler.value << session
|
33
|
+
handler.save
|
34
|
+
end
|
35
|
+
|
36
|
+
def update_sessions(resource, &block)
|
37
|
+
handler = get_session_handler(resource)
|
38
|
+
sessions = filter_sessions(handler.value)
|
39
|
+
|
40
|
+
sessions.each do |session|
|
41
|
+
yield(session)
|
42
|
+
end
|
43
|
+
|
44
|
+
handler.value = sessions
|
45
|
+
handler.save
|
46
|
+
end
|
47
|
+
|
48
|
+
def get_session_handler(value)
|
49
|
+
resource_identifier =
|
50
|
+
if is_session_identifier?(value)
|
51
|
+
extract_resource_identifier(value)
|
52
|
+
elsif is_resource_identifier?(value)
|
53
|
+
value
|
54
|
+
else
|
55
|
+
build_resource_identifier(value)
|
56
|
+
end
|
57
|
+
|
58
|
+
@session_handler ||= Chredis::Json.new resource_identifier
|
59
|
+
end
|
60
|
+
|
61
|
+
# TODO: validate using start_with?
|
62
|
+
def is_session_identifier?(value)
|
63
|
+
value.is_a?(String) &&
|
64
|
+
value.count('#').eql?(2)
|
65
|
+
end
|
66
|
+
|
67
|
+
# TODO: validate using start_with?
|
68
|
+
def is_resource_identifier?(value)
|
69
|
+
value.is_a?(String) &&
|
70
|
+
value.count('#').eql?(1)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Authralia
|
2
|
+
module AuthenticatedSession
|
3
|
+
class Creation < Base
|
4
|
+
def initialize(resource, controller)
|
5
|
+
@resource = resource
|
6
|
+
@controller = controller
|
7
|
+
end
|
8
|
+
|
9
|
+
def call
|
10
|
+
session_guid = SecureRandom.uuid
|
11
|
+
browser_guid = SecureRandom.uuid
|
12
|
+
resource_identifier = build_resource_identifier(@resource)
|
13
|
+
|
14
|
+
handler = get_session_handler(@resource)
|
15
|
+
sessions = filter_sessions(handler.value)
|
16
|
+
|
17
|
+
add_session(
|
18
|
+
@resource,
|
19
|
+
{
|
20
|
+
guid: session_guid,
|
21
|
+
browser_guid: @controller.send(:cookies)[:browser_guid],
|
22
|
+
login_at: Time.now.utc,
|
23
|
+
login_ip: @controller.request.ip,
|
24
|
+
host: @controller.request.host,
|
25
|
+
user_agent: @controller.request.user_agent,
|
26
|
+
expires_at: Time.now.utc + Authralia.expires_in.hours,
|
27
|
+
is_accepted: sessions.size.zero?,
|
28
|
+
new_session_guids: []
|
29
|
+
}
|
30
|
+
)
|
31
|
+
|
32
|
+
response(
|
33
|
+
status: SUCCESS,
|
34
|
+
payload: {
|
35
|
+
browser_guid: browser_guid,
|
36
|
+
session_identifier: "#{resource_identifier}##{session_guid}"
|
37
|
+
}
|
38
|
+
)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module Authralia
|
2
|
+
module AuthenticatedSession
|
3
|
+
class Rejection < Removal
|
4
|
+
def initialize(session_identifier, guid)
|
5
|
+
@session_identifier = session_identifier
|
6
|
+
@guid = guid
|
7
|
+
end
|
8
|
+
|
9
|
+
def call
|
10
|
+
response(status: FAIL) if @session_identifier.blank?
|
11
|
+
remove_session_based_on_guid(@session_identifier, @guid)
|
12
|
+
response(status: SUCCESS)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Authralia
|
2
|
+
module AuthenticatedSession
|
3
|
+
class Removal < Base
|
4
|
+
def initialize(session_identifier)
|
5
|
+
@session_identifier = session_identifier
|
6
|
+
end
|
7
|
+
|
8
|
+
def call
|
9
|
+
guid = extract_guid(@session_identifier)
|
10
|
+
|
11
|
+
response(status: FAIL) if @session_identifier.blank?
|
12
|
+
remove_session_based_on_guid(@session_identifier, guid)
|
13
|
+
response(status: SUCCESS)
|
14
|
+
end
|
15
|
+
|
16
|
+
protected
|
17
|
+
|
18
|
+
def remove_session_based_on_guid(session_identifier, guid)
|
19
|
+
handler = get_session_handler(session_identifier)
|
20
|
+
collection = filter_sessions(handler.value)
|
21
|
+
collection = collection.reject { |s| s[:guid] == guid }
|
22
|
+
|
23
|
+
collection.each do |session|
|
24
|
+
session[:new_session_guids].delete(guid)
|
25
|
+
end
|
26
|
+
|
27
|
+
if collection.size.zero?
|
28
|
+
handler.clear
|
29
|
+
else
|
30
|
+
handler.value = collection
|
31
|
+
handler.save
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
module Authralia
|
2
|
+
module AuthenticatedSession
|
3
|
+
class Validation < Base
|
4
|
+
AMOGUS = :AMOGUS
|
5
|
+
|
6
|
+
def initialize(session_identifier, controller)
|
7
|
+
@controller = controller
|
8
|
+
@session_identifier = session_identifier
|
9
|
+
end
|
10
|
+
|
11
|
+
def call
|
12
|
+
login_message = 'Please login to continue'
|
13
|
+
|
14
|
+
unless is_session_identifier?(@session_identifier)
|
15
|
+
return response(status: FAIL, message: login_message)
|
16
|
+
end
|
17
|
+
|
18
|
+
resource_name, resource_id, guid = @session_identifier.split('#')
|
19
|
+
resource = resource_name.constantize.find_by_id(resource_id)
|
20
|
+
|
21
|
+
handler = get_session_handler(resource)
|
22
|
+
sessions = filter_sessions(handler.value)
|
23
|
+
session = sessions.detect { |s| s[:guid].eql? guid }
|
24
|
+
|
25
|
+
if resource.blank? || session.blank?
|
26
|
+
return response(status: FAIL, message: login_message)
|
27
|
+
end
|
28
|
+
|
29
|
+
if is_session_expire?(session)
|
30
|
+
response(status: FAIL, message: "Session expire. #{login_message}")
|
31
|
+
elsif is_session_sus?(guid, sessions)
|
32
|
+
response(status: AMOGUS, message: 'AMOGUS')
|
33
|
+
else
|
34
|
+
if sessions.size >= 1 && !session[:is_accepted]
|
35
|
+
sessions = notify_other_sessions(sessions, guid)
|
36
|
+
handler.value = sessions
|
37
|
+
handler.save
|
38
|
+
end
|
39
|
+
|
40
|
+
response(
|
41
|
+
status: SUCCESS,
|
42
|
+
payload: { resource:, session: }
|
43
|
+
)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def notify_other_sessions(sessions, guid)
|
50
|
+
sessions.each do |s|
|
51
|
+
new_session_guids = s[:new_session_guids]
|
52
|
+
is_accepted_session = s[:guid] != guid && s[:is_accepted]
|
53
|
+
|
54
|
+
if is_accepted_session && !new_session_guids.include?(guid)
|
55
|
+
s.update(new_session_guids: new_session_guids << guid)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def is_session_expire?(session)
|
61
|
+
(Time.now.utc - session[:expires_at].to_time) >= Authralia.expires_in.hours
|
62
|
+
end
|
63
|
+
|
64
|
+
def is_session_sus?(guid, sessions)
|
65
|
+
sessions.detect do |session|
|
66
|
+
is_guid_valid?(guid, session) &&
|
67
|
+
is_browser_valid?(session) &&
|
68
|
+
is_ip_valid?(session) &&
|
69
|
+
is_host_valid?(session) &&
|
70
|
+
is_user_agent_valid?(session)
|
71
|
+
end.blank?
|
72
|
+
end
|
73
|
+
|
74
|
+
def is_guid_valid?(guid, session)
|
75
|
+
session[:guid] == guid
|
76
|
+
end
|
77
|
+
|
78
|
+
def is_browser_valid?(session)
|
79
|
+
session[:browser_guid] == @controller.send(:cookies)[:browser_guid]
|
80
|
+
end
|
81
|
+
|
82
|
+
def is_ip_valid?(session)
|
83
|
+
session[:login_ip] == @controller.request.ip
|
84
|
+
end
|
85
|
+
|
86
|
+
def is_host_valid?(session)
|
87
|
+
session[:host] == @controller.request.host
|
88
|
+
end
|
89
|
+
|
90
|
+
def is_user_agent_valid?(session)
|
91
|
+
session[:user_agent] == @controller.request.user_agent
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
data/lib/authralia.rb
CHANGED
@@ -9,7 +9,7 @@ module Authralia
|
|
9
9
|
end
|
10
10
|
|
11
11
|
module AuthenticatedSession
|
12
|
-
autoload :Base, '
|
12
|
+
autoload :Base, 'authenticated_session/base'
|
13
13
|
autoload :Acceptence, 'authenticated_session/acceptence'
|
14
14
|
autoload :Creation, 'authenticated_session/creation'
|
15
15
|
autoload :Rejection, 'authenticated_session/rejection'
|
@@ -26,8 +26,8 @@ module Authralia
|
|
26
26
|
autoload :ThydneyTheProtector, 'models/thydney_the_protector'
|
27
27
|
end
|
28
28
|
|
29
|
-
mattr_accessor :
|
30
|
-
@@
|
29
|
+
mattr_accessor :single_active_session_for
|
30
|
+
@@single_active_session_for = []
|
31
31
|
|
32
32
|
mattr_accessor :expires_in
|
33
33
|
@@expires_in = 1.hour
|
@@ -41,11 +41,11 @@ module Authralia
|
|
41
41
|
end
|
42
42
|
|
43
43
|
ActiveSupport.on_load(:action_controller_base) do
|
44
|
-
include
|
44
|
+
include Authralia::Controllers::Helpers
|
45
45
|
end
|
46
46
|
|
47
47
|
ActiveSupport.on_load(:active_record) do
|
48
|
-
include
|
48
|
+
include Authralia::Models::ThydneyTheProtector
|
49
49
|
end
|
50
50
|
|
51
51
|
module ActionDispatch::Routing
|
@@ -0,0 +1,112 @@
|
|
1
|
+
module Authralia
|
2
|
+
module Controllers
|
3
|
+
module Helpers
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
included do
|
7
|
+
add_flash_types :error
|
8
|
+
|
9
|
+
protected
|
10
|
+
|
11
|
+
Authralia.resource_class_names.each do |resource_class_name|
|
12
|
+
resource_name = resource_class_name.underscore
|
13
|
+
|
14
|
+
attr_reader "current_#{resource_name}".to_sym,
|
15
|
+
"current_#{resource_name}_session".to_sym
|
16
|
+
|
17
|
+
helper_method "current_#{resource_name}".to_sym,
|
18
|
+
"current_#{resource_name}_session".to_sym
|
19
|
+
|
20
|
+
# SECURITY: CSRF countermeasure
|
21
|
+
rescue_from ActionController::InvalidAuthenticityToken,
|
22
|
+
with: "logout_#{resource_name}!".to_sym
|
23
|
+
|
24
|
+
define_method "authenticate_#{resource_name}!" do
|
25
|
+
validation_response = AuthenticatedSession::Validation.call(
|
26
|
+
session[resource_name], self
|
27
|
+
)
|
28
|
+
|
29
|
+
case validation_response.status
|
30
|
+
when AuthenticatedSession::Validation::SUCCESS
|
31
|
+
instance_variable_set(
|
32
|
+
"@current_#{resource_name}",
|
33
|
+
validation_response.payload[:resource]
|
34
|
+
)
|
35
|
+
|
36
|
+
instance_variable_set(
|
37
|
+
"@current_#{resource_name}_session",
|
38
|
+
validation_response.payload[:session]
|
39
|
+
)
|
40
|
+
when AuthenticatedSession::Validation::AMOGUS
|
41
|
+
reset_session
|
42
|
+
|
43
|
+
when_authtralia_fail(validation_response)
|
44
|
+
when AuthenticatedSession::Validation::FAIL
|
45
|
+
send("logout_#{resource_name}!")
|
46
|
+
when_authtralia_fail(validation_response)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
define_method "logout_#{resource_name}!" do
|
51
|
+
return unless session[resource_name]
|
52
|
+
|
53
|
+
removal_response = AuthenticatedSession::Removal.call(
|
54
|
+
session[resource_name]
|
55
|
+
)
|
56
|
+
|
57
|
+
case removal_response.status
|
58
|
+
when Authenticate::Resource::SUCCESS
|
59
|
+
session.delete(resource_name)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
define_method "login_#{resource_name}!" do |auth_data, &block|
|
64
|
+
set_browser_guid
|
65
|
+
|
66
|
+
auth_res_response = Authenticate::Resource.call(
|
67
|
+
resource_class_name, auth_data
|
68
|
+
)
|
69
|
+
|
70
|
+
case auth_res_response.status
|
71
|
+
when Authenticate::Resource::SUCCESS
|
72
|
+
resource = auth_res_response.payload
|
73
|
+
|
74
|
+
if Authralia.single_active_session_for.include?(resource_class_name) &&
|
75
|
+
AuthenticatedSession::Wipeout.call(resource)
|
76
|
+
end
|
77
|
+
|
78
|
+
# SECURITY: multi device and session fixation countermeasure
|
79
|
+
authed_sess_response = AuthenticatedSession::Creation.call(
|
80
|
+
resource, self
|
81
|
+
)
|
82
|
+
|
83
|
+
# SECURITY: session fixation countermeasure
|
84
|
+
session.delete(resource_name)
|
85
|
+
|
86
|
+
session[resource_name] =
|
87
|
+
authed_sess_response.payload[:session_identifier]
|
88
|
+
|
89
|
+
block.call(true, auth_res_response) if block
|
90
|
+
when Authenticate::Resource::FAIL
|
91
|
+
block.call(false, auth_res_response) if block
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
define_method "accept_#{resource_name}_session!" do |guid:|
|
96
|
+
AuthenticatedSession::Acceptence.call(session[resource_name], guid)
|
97
|
+
end
|
98
|
+
|
99
|
+
define_method "reject_#{resource_name}_session!" do |guid:|
|
100
|
+
AuthenticatedSession::Rejection.call(session[resource_name], guid)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def set_browser_guid
|
106
|
+
if cookies[:browser_guid].blank?
|
107
|
+
cookies.permanent.signed.encrypted[:browser_guid] = SecureRandom.uuid
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Authralia
|
2
|
+
module Models
|
3
|
+
module ThydneyTheProtector
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
class_methods do
|
7
|
+
def thydney_protects(*fields)
|
8
|
+
has_secure_password
|
9
|
+
|
10
|
+
define_singleton_method :protect_thcope do |params|
|
11
|
+
field = fields.detect { |f| params[f].present? }
|
12
|
+
|
13
|
+
return nil unless field
|
14
|
+
|
15
|
+
find_by("#{field}": params[field])
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: authralia
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Gilang Mugni Respaty
|
@@ -9,14 +9,54 @@ autorequire:
|
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
11
|
date: 2022-11-16 00:00:00.000000000 Z
|
12
|
-
dependencies:
|
13
|
-
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: chredis
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: serpis_objek
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
description: A authentication dedicated to Mike Tyson based on his favorite place,
|
42
|
+
Australia
|
14
43
|
email: gilmoregarland@gmail.com
|
15
44
|
executables: []
|
16
45
|
extensions: []
|
17
46
|
extra_rdoc_files: []
|
18
47
|
files:
|
48
|
+
- lib/authenticate/base.rb
|
49
|
+
- lib/authenticate/resource.rb
|
50
|
+
- lib/authenticated_session/acceptence.rb
|
51
|
+
- lib/authenticated_session/base.rb
|
52
|
+
- lib/authenticated_session/creation.rb
|
53
|
+
- lib/authenticated_session/rejection.rb
|
54
|
+
- lib/authenticated_session/removal.rb
|
55
|
+
- lib/authenticated_session/validation.rb
|
56
|
+
- lib/authenticated_session/wipeout.rb
|
19
57
|
- lib/authralia.rb
|
58
|
+
- lib/controllers/helpers.rb
|
59
|
+
- lib/models/thydney_the_protector.rb
|
20
60
|
homepage: https://rubygems.org/gems/hola
|
21
61
|
licenses:
|
22
62
|
- MIT
|