shakha 0.1.0 → 0.1.1
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/app/controllers/shakha/application_controller.rb +1 -1
- data/app/controllers/shakha/auth_controller.rb +14 -14
- data/app/models/shakha/user.rb +3 -2
- data/app/views/shakha/auth/error.html.erb +18 -0
- data/{generators → lib/generators}/shakha/install_generator.rb +3 -15
- data/{generators → lib/generators}/shakha/templates/initializer.rb.erb +1 -1
- data/{generators → lib/generators}/shakha/templates/migration.rb.erb +6 -6
- data/lib/shakha/config.rb +4 -1
- data/lib/shakha/engine.rb +12 -79
- data/lib/shakha/pkce.rb +39 -11
- data/lib/shakha/version.rb +1 -1
- data/lib/shakha.rb +6 -0
- metadata +7 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 82e8c2eda1b9b817ce681d22f4e5a331d15e0c59588c972067d1fa432db03b62
|
|
4
|
+
data.tar.gz: 979e1a3513ac57e1b1d6a81fffd954c1fb911fbb8d0cca729b65ce161bbe9f44
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 80529b985eb5d490eada76a0829a9aa3311ec30af375c83b4f33ef0cb5e2399e4b1f75242bb8d5ee843e8b49d583da36ac84f53d0953b33d96868bf80dd9bb33
|
|
7
|
+
data.tar.gz: 15777157e9a5fb3746019943f760b57ef92a9b10680056d0178f65d42392ed0391da8f1c3bf72c90e5c32d2c6110dac3420a8e1062cc0cf3d999b50309888c3d
|
|
@@ -9,7 +9,7 @@ module Shakha
|
|
|
9
9
|
|
|
10
10
|
layout -> { false if request.format == :json }
|
|
11
11
|
|
|
12
|
-
rescue_from
|
|
12
|
+
rescue_from ActionController::InvalidAuthenticityToken, with: :invalid_csrf_token
|
|
13
13
|
|
|
14
14
|
private
|
|
15
15
|
|
|
@@ -20,15 +20,14 @@ module Shakha
|
|
|
20
20
|
|
|
21
21
|
google_auth_url = build_google_auth_url(pkce)
|
|
22
22
|
|
|
23
|
-
redirect_to google_auth_url
|
|
23
|
+
redirect_to google_auth_url, allow_other_host: true
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def callback
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
exchange_code_for_tokens(params[:code], verifier)
|
|
27
|
+
pkce_result = verify_pkce!(params[:code], params[:state])
|
|
28
|
+
exchange_code_for_tokens(params[:code], pkce_result[:verifier], pkce_result[:return_to])
|
|
30
29
|
rescue PKCEError, GoogleOAuthError => e
|
|
31
|
-
redirect_to shakha.
|
|
30
|
+
redirect_to "/auth/shakha/error?message=#{URI.encode_www_form_component(e.message)}"
|
|
32
31
|
end
|
|
33
32
|
|
|
34
33
|
def token
|
|
@@ -56,16 +55,18 @@ module Shakha
|
|
|
56
55
|
private
|
|
57
56
|
|
|
58
57
|
def find_or_create_client
|
|
59
|
-
origin =
|
|
58
|
+
origin = request.origin || Shakha.config.app_origin
|
|
59
|
+
origin_uri = URI.parse(origin).origin
|
|
60
60
|
|
|
61
|
-
Shakha::Client.find_or_create_by!(origin:
|
|
62
|
-
client.name = URI.parse(
|
|
61
|
+
Shakha::Client.find_or_create_by!(origin: origin_uri) do |client|
|
|
62
|
+
client.name = URI.parse(origin).host
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
def build_google_auth_url(pkce)
|
|
67
67
|
client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
|
|
68
|
-
|
|
68
|
+
base_url = Shakha.config.service_base_url || "http://localhost:3000"
|
|
69
|
+
redirect_uri = "#{base_url}/auth/shakha/callback"
|
|
69
70
|
|
|
70
71
|
scopes = ["openid", "email", "profile"].join(" ")
|
|
71
72
|
scopes += " https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile" if params[:request_pii]
|
|
@@ -87,10 +88,11 @@ module Shakha
|
|
|
87
88
|
end.to_s
|
|
88
89
|
end
|
|
89
90
|
|
|
90
|
-
def exchange_code_for_tokens(code, verifier)
|
|
91
|
+
def exchange_code_for_tokens(code, verifier, return_to = "/")
|
|
91
92
|
client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
|
|
92
93
|
client_secret = Shakha.config.google_client_secret || ENV["GOOGLE_CLIENT_SECRET"]
|
|
93
|
-
|
|
94
|
+
base_url = Shakha.config.service_base_url || "http://localhost:3000"
|
|
95
|
+
redirect_uri = "#{base_url}/auth/shakha/callback"
|
|
94
96
|
|
|
95
97
|
response = http_post(
|
|
96
98
|
"https://oauth2.googleapis.com/token",
|
|
@@ -115,7 +117,7 @@ module Shakha
|
|
|
115
117
|
pairwise_sub = Shakha.derive_pairwise_sub(google_sub)
|
|
116
118
|
|
|
117
119
|
client = find_or_create_client
|
|
118
|
-
user = Shakha::User.find_or_initialize_by(pairwise_sub: pairwise_sub)
|
|
120
|
+
user = Shakha::User.find_or_initialize_by(pairwise_sub: pairwise_sub, client: client)
|
|
119
121
|
|
|
120
122
|
if params[:request_pii] && payload["email"]
|
|
121
123
|
user.assign_attributes(
|
|
@@ -140,8 +142,6 @@ module Shakha
|
|
|
140
142
|
expires: Shakha.config.session_lifetime.from_now
|
|
141
143
|
}
|
|
142
144
|
|
|
143
|
-
return_to = pkce_state&.dig(:return_to) || "/"
|
|
144
|
-
|
|
145
145
|
redirect_to return_to
|
|
146
146
|
end
|
|
147
147
|
|
data/app/models/shakha/user.rb
CHANGED
|
@@ -4,10 +4,11 @@ module Shakha
|
|
|
4
4
|
class User < ::ApplicationRecord
|
|
5
5
|
self.table_name = "shakha_users"
|
|
6
6
|
|
|
7
|
+
belongs_to :client, class_name: "Shakha::Client"
|
|
7
8
|
has_many :sessions, class_name: "Shakha::Session", dependent: :destroy
|
|
8
9
|
|
|
9
|
-
validates :pairwise_sub, presence: true
|
|
10
|
-
validates :email, uniqueness:
|
|
10
|
+
validates :pairwise_sub, presence: true
|
|
11
|
+
validates :email, uniqueness: { scope: :client_id }, allow_blank: true
|
|
11
12
|
|
|
12
13
|
def can_access?(resource)
|
|
13
14
|
true
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<% content_for :title, "Authentication Error" %>
|
|
2
|
+
|
|
3
|
+
<div class="shakha-container">
|
|
4
|
+
<div class="shakha-card shakha-card-error">
|
|
5
|
+
<div class="shakha-error-icon">
|
|
6
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
7
|
+
<circle cx="12" cy="12" r="10"/>
|
|
8
|
+
<line x1="12" y1="8" x2="12" y2="12"/>
|
|
9
|
+
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
10
|
+
</svg>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<h1>Authentication Failed</h1>
|
|
14
|
+
<p class="shakha-error-message"><%= params[:message] || "An error occurred" %></p>
|
|
15
|
+
|
|
16
|
+
<%= link_to "Try Again", "/auth/shakha", class: "shakha-button shakha-button-primary" %>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
-
# This generator creates a migration for the Shakha tables
|
|
3
|
-
|
|
4
|
-
require "rails/generators/active_record/migration"
|
|
5
|
-
require "rails/generators/active_record/migration/migration_generator"
|
|
6
2
|
|
|
7
3
|
module Shakha
|
|
8
4
|
class InstallGenerator < Rails::Generators::Base
|
|
@@ -17,21 +13,13 @@ module Shakha
|
|
|
17
13
|
def create_migration
|
|
18
14
|
return if options[:skip_migration]
|
|
19
15
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
migration_version: migration_version
|
|
24
|
-
)
|
|
16
|
+
sleep 1
|
|
17
|
+
migration_number = Time.now.strftime("%Y%m%d%H%M%S")
|
|
18
|
+
template "migration.rb.erb", "db/migrate/#{migration_number}_create_shakha_tables.rb"
|
|
25
19
|
end
|
|
26
20
|
|
|
27
21
|
def add_routes
|
|
28
22
|
route 'mount Shakha::Engine => "/auth/shakha", as: :shakha'
|
|
29
23
|
end
|
|
30
|
-
|
|
31
|
-
private
|
|
32
|
-
|
|
33
|
-
def migration_version
|
|
34
|
-
">= 7.1" ? "[7.1]" : ""
|
|
35
|
-
end
|
|
36
24
|
end
|
|
37
25
|
end
|
|
@@ -20,7 +20,7 @@ Shakha.setup do |config|
|
|
|
20
20
|
config.google_client_id = ENV["GOOGLE_CLIENT_ID"]
|
|
21
21
|
config.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
|
|
22
22
|
config.issuer = ENV.fetch("SHAKHA_ISSUER", "https://shakha.dev")
|
|
23
|
-
config.session_lifetime =
|
|
23
|
+
config.session_lifetime = 30.days
|
|
24
24
|
|
|
25
25
|
# JWT signing keys (required for service mode)
|
|
26
26
|
config.signing_key = ENV["SHAKHA_SIGNING_KEY"]
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
class CreateShakhaTables < ActiveRecord::Migration[7.1]
|
|
4
4
|
def change
|
|
5
|
-
create_table :shakha_clients
|
|
5
|
+
create_table :shakha_clients do |t|
|
|
6
6
|
t.string :name, null: false
|
|
7
7
|
t.string :origin, null: false
|
|
8
8
|
t.string :client_id
|
|
@@ -14,12 +14,12 @@ class CreateShakhaTables < ActiveRecord::Migration[7.1]
|
|
|
14
14
|
add_index :shakha_clients, :origin, unique: true
|
|
15
15
|
add_index :shakha_clients, :client_id, unique: true
|
|
16
16
|
|
|
17
|
-
create_table :shakha_users
|
|
17
|
+
create_table :shakha_users do |t|
|
|
18
18
|
t.string :pairwise_sub, null: false
|
|
19
19
|
t.string :email
|
|
20
20
|
t.string :name
|
|
21
21
|
t.string :picture
|
|
22
|
-
t.references :client,
|
|
22
|
+
t.references :client, null: false
|
|
23
23
|
|
|
24
24
|
t.timestamps
|
|
25
25
|
end
|
|
@@ -27,11 +27,11 @@ class CreateShakhaTables < ActiveRecord::Migration[7.1]
|
|
|
27
27
|
add_index :shakha_users, :pairwise_sub, unique: true
|
|
28
28
|
add_index :shakha_users, :email
|
|
29
29
|
|
|
30
|
-
create_table :shakha_sessions
|
|
30
|
+
create_table :shakha_sessions do |t|
|
|
31
31
|
t.string :token, null: false
|
|
32
32
|
t.string :jti, null: false
|
|
33
|
-
t.references :user,
|
|
34
|
-
t.references :client,
|
|
33
|
+
t.references :user, null: false
|
|
34
|
+
t.references :client, null: false
|
|
35
35
|
t.string :ip_address
|
|
36
36
|
t.string :user_agent
|
|
37
37
|
|
data/lib/shakha/config.rb
CHANGED
data/lib/shakha/engine.rb
CHANGED
|
@@ -1,93 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "ostruct"
|
|
4
|
-
|
|
5
3
|
module Shakha
|
|
6
4
|
class Engine < ::Rails::Engine
|
|
7
5
|
isolate_namespace Shakha
|
|
8
6
|
|
|
9
7
|
config.app_middleware.use Shakha::Middleware
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
class EngineRouter
|
|
18
|
-
def self.draw
|
|
19
|
-
Drawer.new
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
class Drawer
|
|
23
|
-
def initialize
|
|
24
|
-
@routes = []
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def resources(*args, &block)
|
|
28
|
-
resource_options = args.last.is_a?(Hash) ? args.pop : {}
|
|
29
|
-
resource_name = args.first
|
|
30
|
-
|
|
31
|
-
@routes << { type: :resources, name: resource_name, options: resource_options, block: block }
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def resource(*args, &block)
|
|
35
|
-
resource_options = args.last.is_a?(Hash) ? args.pop : {}
|
|
36
|
-
resource_name = args.first
|
|
37
|
-
|
|
38
|
-
@routes << { type: :resource, name: resource_name, options: resource_options, block: block }
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def get(path, to:, as: nil)
|
|
42
|
-
@routes << { type: :get, path: path, to: to, as: as }
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def post(path, to:, as: nil)
|
|
46
|
-
@routes << { type: :post, path: path, to: to, as: as }
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def match(path, to:, via:, as: nil)
|
|
50
|
-
@routes << { type: :match, path: path, to: to, via: via, as: as }
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def routes
|
|
54
|
-
@routes
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
initializer "shakha.routes" do |app|
|
|
60
|
-
Shakha::EngineRouter.draw do
|
|
61
|
-
get "/auth/shakha", to: "auth#new", as: :new_auth
|
|
62
|
-
get "/auth/shakha/authorize", to: "auth#authorize", as: :authorize
|
|
63
|
-
get "/auth/shakha/callback", to: "auth#callback", as: :callback
|
|
64
|
-
post "/auth/shakha/token", to: "auth#token", as: :token
|
|
65
|
-
get "/auth/shakha/error", to: "auth#error", as: :error
|
|
9
|
+
# Engine routes - these should be relative paths
|
|
10
|
+
routes do
|
|
11
|
+
root to: "auth#new"
|
|
66
12
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
13
|
+
get "authorize" => "auth#authorize"
|
|
14
|
+
get "callback" => "auth#callback"
|
|
15
|
+
post "token" => "auth#token"
|
|
16
|
+
get "error" => "auth#error"
|
|
70
17
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
18
|
+
get "session" => "session#show"
|
|
19
|
+
post "session/check" => "session#check"
|
|
20
|
+
delete "session" => "session#destroy"
|
|
74
21
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
when :get
|
|
78
|
-
app.routes.append do
|
|
79
|
-
get route[:path], to: route[:to], as: route[:as]
|
|
80
|
-
end
|
|
81
|
-
when :post
|
|
82
|
-
app.routes.append do
|
|
83
|
-
post route[:path], to: route[:to], as: route[:as]
|
|
84
|
-
end
|
|
85
|
-
when :match
|
|
86
|
-
app.routes.append do
|
|
87
|
-
match route[:path], to: route[:to], as: route[:as], via: route[:via]
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
end
|
|
22
|
+
get ".well-known/jwks.json" => "jwks#show"
|
|
23
|
+
get ".well-known/openid-configuration" => "openid#configuration"
|
|
91
24
|
end
|
|
92
25
|
end
|
|
93
26
|
end
|
data/lib/shakha/pkce.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require "active_support/concern"
|
|
4
5
|
|
|
5
6
|
module Shakha
|
|
@@ -8,6 +9,8 @@ module Shakha
|
|
|
8
9
|
|
|
9
10
|
CODE_VERIFIER_LENGTH = 64
|
|
10
11
|
CODE_CHALLENGE_METHOD = "S256"
|
|
12
|
+
PKCE_COOKIE_NAME = "shakha_pkce"
|
|
13
|
+
PKCE_COOKIE_EXPIRY_SECONDS = 600
|
|
11
14
|
|
|
12
15
|
class << self
|
|
13
16
|
def generate_code_verifier
|
|
@@ -30,31 +33,56 @@ module Shakha
|
|
|
30
33
|
verifier = PKCEMixin.generate_code_verifier
|
|
31
34
|
challenge = PKCEMixin.generate_code_challenge(verifier)
|
|
32
35
|
state = SecureRandom.urlsafe_base64(32)
|
|
36
|
+
return_to = params[:return_to] || "/"
|
|
33
37
|
|
|
34
|
-
|
|
38
|
+
pkce_record = {
|
|
35
39
|
verifier: verifier,
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
return_to: return_to
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
cookies[PKCE_COOKIE_NAME] = {
|
|
44
|
+
value: pkce_record.merge(state: state).to_json,
|
|
45
|
+
httponly: true,
|
|
46
|
+
secure: Rails.env.production?,
|
|
47
|
+
same_site: :lax,
|
|
48
|
+
expires: Time.now.utc + PKCE_COOKIE_EXPIRY_SECONDS
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
{ challenge: challenge, state: state }
|
|
41
52
|
end
|
|
42
53
|
|
|
43
|
-
def verify_pkce!(code_verifier)
|
|
44
|
-
|
|
54
|
+
def verify_pkce!(code_verifier, state_param)
|
|
55
|
+
pkce_json = cookies[PKCE_COOKIE_NAME]
|
|
56
|
+
|
|
57
|
+
raise PKCEError, "No PKCE session found" unless pkce_json
|
|
58
|
+
|
|
59
|
+
pkce_data = JSON.parse(pkce_json).with_indifferent_access
|
|
60
|
+
|
|
61
|
+
raise PKCEError, "No PKCE session found" unless pkce_data
|
|
45
62
|
|
|
46
|
-
|
|
47
|
-
|
|
63
|
+
stored_state = pkce_data[:state]
|
|
64
|
+
stored_verifier = pkce_data[:verifier]
|
|
65
|
+
stored_return_to = pkce_data[:return_to]
|
|
66
|
+
|
|
67
|
+
cookies.delete(PKCE_COOKIE_NAME)
|
|
68
|
+
|
|
69
|
+
raise PKCEError, "State mismatch" unless stored_state == state_param
|
|
48
70
|
|
|
49
71
|
computed = PKCEMixin.generate_code_challenge(code_verifier)
|
|
50
|
-
|
|
72
|
+
code_challenge = params[:code_challenge]
|
|
51
73
|
|
|
52
|
-
|
|
53
|
-
|
|
74
|
+
if code_challenge.present?
|
|
75
|
+
raise PKCEError, "Invalid code verifier" unless computed == code_challenge
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
{ verifier: stored_verifier, return_to: stored_return_to }
|
|
54
79
|
end
|
|
55
80
|
|
|
56
81
|
def pkce_state
|
|
57
|
-
|
|
82
|
+
pkce_json = cookies[PKCE_COOKIE_NAME]
|
|
83
|
+
return nil unless pkce_json
|
|
84
|
+
|
|
85
|
+
JSON.parse(pkce_json).with_indifferent_access
|
|
58
86
|
end
|
|
59
87
|
end
|
|
60
88
|
|
data/lib/shakha/version.rb
CHANGED
data/lib/shakha.rb
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require "shakha/version"
|
|
4
4
|
require "shakha/config"
|
|
5
|
+
require "shakha/pairwise"
|
|
6
|
+
require "shakha/jwt_handler"
|
|
7
|
+
require "shakha/pkce"
|
|
8
|
+
require "shakha/error_handler"
|
|
9
|
+
require "shakha/controller_helpers"
|
|
10
|
+
require "shakha/middleware"
|
|
5
11
|
require "shakha/engine"
|
|
6
12
|
|
|
7
13
|
module Shakha
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: shakha
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Asrat
|
|
@@ -27,14 +27,14 @@ dependencies:
|
|
|
27
27
|
name: activesupport
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
|
-
- - "
|
|
30
|
+
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
32
|
version: '7.1'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
|
-
- - "
|
|
37
|
+
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '7.1'
|
|
40
40
|
description: |
|
|
@@ -58,12 +58,13 @@ files:
|
|
|
58
58
|
- app/models/shakha/session.rb
|
|
59
59
|
- app/models/shakha/user.rb
|
|
60
60
|
- app/views/shakha/auth/callback.html.erb
|
|
61
|
+
- app/views/shakha/auth/error.html.erb
|
|
61
62
|
- app/views/shakha/auth/new.html.erb
|
|
62
63
|
- app/views/shakha/errors/show.html.erb
|
|
63
64
|
- app/views/shakha/layouts/shakha.html.erb
|
|
64
|
-
- generators/shakha/install_generator.rb
|
|
65
|
-
- generators/shakha/templates/initializer.rb.erb
|
|
66
|
-
- generators/shakha/templates/migration.rb.erb
|
|
65
|
+
- lib/generators/shakha/install_generator.rb
|
|
66
|
+
- lib/generators/shakha/templates/initializer.rb.erb
|
|
67
|
+
- lib/generators/shakha/templates/migration.rb.erb
|
|
67
68
|
- lib/shakha.rb
|
|
68
69
|
- lib/shakha/config.rb
|
|
69
70
|
- lib/shakha/controller_helpers.rb
|