salvia_rb 0.1.5

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.
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tilt"
4
+
5
+ module Salvia
6
+ # View Component の基底クラス
7
+ #
8
+ # @example
9
+ # class UserCardComponent < Salvia::Component
10
+ # def initialize(user:)
11
+ # @user = user
12
+ # end
13
+ # end
14
+ #
15
+ # # View
16
+ # <%= component "user_card", user: @user %>
17
+ class Component
18
+ include Salvia::Router.helpers
19
+ include Salvia::Helpers
20
+
21
+ def initialize(**kwargs)
22
+ kwargs.each do |key, value|
23
+ instance_variable_set("@#{key}", value)
24
+ end
25
+ end
26
+
27
+ # コンポーネントをレンダリングする
28
+ #
29
+ # @param view_context [Object] ビューコンテキスト(コントローラーなど)
30
+ # @param block [Proc] コンテンツブロック
31
+ # @return [String] レンダリング結果
32
+ def render_in(view_context, &block)
33
+ @view_context = view_context
34
+ template_path = resolve_template_path
35
+
36
+ unless File.exist?(template_path)
37
+ raise Error, "Component template not found: #{template_path}"
38
+ end
39
+
40
+ # コンポーネント自身のインスタンス変数をローカル変数として渡す
41
+ locals = instance_variables_hash
42
+
43
+ template = Tilt.new(template_path)
44
+ template.render(self, locals, &block)
45
+ end
46
+
47
+ # ビューコンテキストへの委譲(ヘルパーメソッドなどで使用)
48
+ def method_missing(method, *args, &block)
49
+ if @view_context&.respond_to?(method)
50
+ @view_context.send(method, *args, &block)
51
+ else
52
+ super
53
+ end
54
+ end
55
+
56
+ def respond_to_missing?(method, include_private = false)
57
+ @view_context&.respond_to?(method, include_private) || super
58
+ end
59
+
60
+ private
61
+
62
+ def resolve_template_path
63
+ # UserCardComponent -> user_card_component.html.erb
64
+ name = self.class.name.underscore
65
+ File.join(Salvia.root, "app", "components", "#{name}.html.erb")
66
+ end
67
+
68
+ def instance_variables_hash
69
+ instance_variables
70
+ .reject { |v| v.to_s.start_with?("@_") || v == :@view_context }
71
+ .each_with_object({}) { |v, h| h[v.to_s.delete("@").to_sym] = instance_variable_get(v) }
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tilt"
4
+ require "tilt/erubi"
5
+ require "rack"
6
+
7
+ module Salvia
8
+ # Smart Rendering 対応の基底コントローラークラス
9
+ #
10
+ # Smart Rendering は HTMX リクエストを自動検出し、
11
+ # 部分更新時にはレイアウトを除外してレンダリングします。
12
+ #
13
+ # @example
14
+ # class HomeController < Salvia::Controller
15
+ # def index
16
+ # @todos = Todo.all
17
+ # render "home/index"
18
+ # end
19
+ #
20
+ # def create
21
+ # Todo.create!(title: params["title"])
22
+ # @todos = Todo.all
23
+ # render "home/_list", locals: { todos: @todos }
24
+ # end
25
+ # end
26
+ #
27
+ class Controller
28
+ attr_reader :request, :response, :params
29
+
30
+ include Salvia::Router.helpers
31
+ include Salvia::Helpers
32
+
33
+ def initialize(request, response, route_params = {})
34
+ @request = request
35
+ @response = response
36
+ @params = build_params(request, route_params)
37
+ @rendered = false
38
+ end
39
+
40
+ # コントローラーアクションを実行
41
+ def process(action_name)
42
+ send(action_name)
43
+ render(default_template(action_name)) unless @rendered
44
+ end
45
+
46
+ # Smart Rendering 対応のテンプレートレンダリング
47
+ #
48
+ # @param template [String, Hash] テンプレートパスまたはオプション
49
+ # @param locals [Hash] テンプレートに渡すローカル変数
50
+ # @param layout [String, nil, false] レイアウト(nil = 自動, false = なし)
51
+ # @param status [Integer] HTTP ステータスコード
52
+ def render(template = nil, locals: {}, layout: nil, status: 200, **options)
53
+ # ネストしたレンダリング(ビュー内の render)かどうかの判定
54
+ is_top_level_render = !@rendered
55
+ @rendered = true
56
+
57
+ # オプション引数の処理 (Rails-like)
58
+ if template.is_a?(Hash)
59
+ options = template.merge(options)
60
+ template = nil
61
+ end
62
+
63
+ # status オプションの処理
64
+ status = options[:status] if options[:status]
65
+ response.status = status
66
+
67
+ # plain: "text"
68
+ if options[:plain]
69
+ response["content-type"] = "text/plain; charset=utf-8"
70
+ response.write(options[:plain])
71
+ return
72
+ end
73
+
74
+ # json: { key: "value" }
75
+ if options[:json]
76
+ response["content-type"] = "application/json; charset=utf-8"
77
+ response.write(options[:json].to_json)
78
+ return
79
+ end
80
+
81
+ # partial: "path/to/partial"
82
+ if options[:partial]
83
+ template = options[:partial]
84
+
85
+ # パーシャルの場合はファイル名の先頭に _ を付与
86
+ dirname = File.dirname(template)
87
+ basename = File.basename(template)
88
+ unless basename.start_with?("_")
89
+ basename = "_#{basename}"
90
+ end
91
+
92
+ # ディレクトリ指定がない場合は現在のコントローラディレクトリを使用
93
+ if dirname == "."
94
+ controller_dir = self.class.name.sub(/Controller$/, "").downcase
95
+ template = File.join(controller_dir, basename)
96
+ else
97
+ template = File.join(dirname, basename)
98
+ end
99
+
100
+ layout = false
101
+ end
102
+
103
+ # template が指定されていない場合はエラー(通常は process メソッドでデフォルトが渡される)
104
+ raise ArgumentError, "テンプレートを指定してください" if template.nil?
105
+
106
+ response["content-type"] = "text/html; charset=utf-8"
107
+
108
+ # インスタンス変数をテンプレートに渡す
109
+ template_locals = instance_variables_hash.merge(locals)
110
+
111
+ # メインテンプレートをレンダリング
112
+ content = render_template(template, template_locals)
113
+
114
+ # Smart Rendering: HTMX リクエストはデフォルトでレイアウトなし
115
+ use_layout = determine_layout(layout, template)
116
+
117
+ if use_layout
118
+ body = render_template(use_layout, template_locals) { content }
119
+ else
120
+ body = content
121
+ end
122
+
123
+ if is_top_level_render
124
+ response.write(body)
125
+ else
126
+ body
127
+ end
128
+ end
129
+
130
+ # パーシャルテンプレートをレンダリング(レイアウトなし)
131
+ def render_partial(template, locals: {}, status: 200)
132
+ render(template, locals: locals, layout: false, status: status)
133
+ end
134
+
135
+ # 別の URL にリダイレクト
136
+ #
137
+ # @param url [String] リダイレクト先 URL
138
+ # @param status [Integer] HTTP ステータスコード(デフォルト: 自動判定)
139
+ # POST/PATCH/DELETE からのリダイレクトは 303 See Other を使用
140
+ # GET/HEAD からのリダイレクトは 302 Found を使用
141
+ def redirect_to(url, status: nil)
142
+ @rendered = true
143
+
144
+ # ステータスコードの自動判定
145
+ # POST/PATCH/DELETE からのリダイレクトは 303 (See Other) を使用
146
+ # これにより、ブラウザは必ず GET でリダイレクト先にアクセスする
147
+ if status.nil?
148
+ status = %w[POST PATCH PUT DELETE].include?(request.request_method) ? 303 : 302
149
+ end
150
+
151
+ response.status = status
152
+ response["location"] = url
153
+ response["content-type"] = "text/html; charset=utf-8"
154
+
155
+ # HTMX リクエストには HX-Redirect ヘッダーを使用
156
+ if htmx_request?
157
+ response["hx-redirect"] = url
158
+ end
159
+ end
160
+
161
+ # セッションにアクセス
162
+ def session
163
+ request.session
164
+ end
165
+
166
+ # Flash メッセージにアクセス
167
+ def flash
168
+ @flash ||= Flash.new(session)
169
+ end
170
+
171
+ # CSRF トークンを取得
172
+ def csrf_token
173
+ Salvia::CSRF.token(session)
174
+ end
175
+
176
+ # CSRF トークン用の input タグを生成
177
+ def csrf_input_tag
178
+ %(<input type="hidden" name="authenticity_token" value="#{csrf_token}">)
179
+ end
180
+
181
+ # CSRF トークン用の meta タグを生成
182
+ def csrf_meta_tags
183
+ %(<meta name="csrf-param" content="authenticity_token">\n) +
184
+ %(<meta name="csrf-token" content="#{csrf_token}">)
185
+ end
186
+
187
+ # ロガーを取得
188
+ def logger
189
+ Salvia.logger
190
+ end
191
+
192
+ # アセットパスを取得
193
+ def asset_path(source)
194
+ Salvia::Assets.path(source)
195
+ end
196
+
197
+ protected
198
+
199
+ # サブクラスでオーバーライドしてデフォルトレイアウトを設定
200
+ def default_layout
201
+ "layouts/application"
202
+ end
203
+
204
+ private
205
+
206
+ def determine_layout(layout_option, template)
207
+ # 明示的に false = レイアウトなし
208
+ return false if layout_option == false
209
+
210
+ # パーシャル(_ で始まる)はデフォルトでレイアウトなし
211
+ template_name = File.basename(template)
212
+ return false if template_name.start_with?("_")
213
+
214
+ # 指定されたレイアウトまたはデフォルトを使用
215
+ layout_option || default_layout
216
+ end
217
+
218
+ def render_template(template_path, locals = {}, &block)
219
+ full_path = resolve_template_path(template_path)
220
+
221
+ unless File.exist?(full_path)
222
+ raise Error, "テンプレートが見つかりません: #{full_path}"
223
+ end
224
+
225
+ template = Tilt.new(full_path)
226
+ template.render(self, locals, &block)
227
+ end
228
+
229
+ def resolve_template_path(template)
230
+ # 拡張子が含まれている場合
231
+ return File.join(views_path, template) if template.end_with?(".erb")
232
+
233
+ # 一般的な拡張子を試す
234
+ base = File.join(views_path, template)
235
+ ["#{base}.html.erb", "#{base}.erb"].find { |p| File.exist?(p) } || "#{base}.html.erb"
236
+ end
237
+
238
+ def views_path
239
+ File.join(Salvia.root, "app", "views")
240
+ end
241
+
242
+ def default_template(action_name)
243
+ controller_name = self.class.name.sub(/Controller$/, "").downcase
244
+ "#{controller_name}/#{action_name}"
245
+ end
246
+
247
+ def instance_variables_hash
248
+ instance_variables
249
+ .reject { |v| v.to_s.start_with?("@_") || %i[@request @response @params @rendered].include?(v) }
250
+ .each_with_object({}) { |v, h| h[v.to_s.delete("@").to_sym] = instance_variable_get(v) }
251
+ end
252
+
253
+ # リクエストパラメータを構築(JSON body を含む)
254
+ def build_params(request, route_params)
255
+ base_params = request.params.dup
256
+
257
+ # Content-Type が JSON の場合、body をパース
258
+ if json_request?(request)
259
+ begin
260
+ body = request.body.read
261
+ request.body.rewind if request.body.respond_to?(:rewind)
262
+ json_params = JSON.parse(body) if body && !body.empty?
263
+ base_params.merge!(json_params) if json_params.is_a?(Hash)
264
+ rescue JSON::ParserError
265
+ # JSONパースエラーは無視
266
+ end
267
+ end
268
+
269
+ base_params.merge(route_params).with_indifferent_access
270
+ end
271
+
272
+ def json_request?(request)
273
+ content_type = request.content_type.to_s
274
+ content_type.include?("application/json")
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Salvia
6
+ # CSRF (Cross-Site Request Forgery) 保護モジュール
7
+ #
8
+ # SSR Islands と互換性のある Global Token 方式を採用
9
+ # Rack::Protection より軽量でシンプルな実装
10
+ #
11
+ module CSRF
12
+ TOKEN_LENGTH = 32
13
+ SESSION_KEY = :csrf_token
14
+ HEADER_NAME = "HTTP_X_CSRF_TOKEN"
15
+ PARAM_NAME = "authenticity_token"
16
+
17
+ class << self
18
+ # セッションから CSRF トークンを取得(なければ生成)
19
+ #
20
+ # @param session [Hash] Rack セッション
21
+ # @return [String] CSRF トークン
22
+ def token(session)
23
+ session[SESSION_KEY] ||= SecureRandom.urlsafe_base64(TOKEN_LENGTH)
24
+ end
25
+
26
+ # CSRF トークンを検証
27
+ #
28
+ # @param session [Hash] Rack セッション
29
+ # @param token [String] 検証するトークン
30
+ # @return [Boolean] 有効なら true
31
+ def valid?(session, token)
32
+ return false if token.nil? || token.empty?
33
+ return false if session[SESSION_KEY].nil?
34
+
35
+ Rack::Utils.secure_compare(session[SESSION_KEY], token)
36
+ end
37
+
38
+ # 安全な HTTP メソッドか判定
39
+ # GET, HEAD, OPTIONS, TRACE は CSRF 検証不要
40
+ #
41
+ # @param method [String] HTTP メソッド
42
+ # @return [Boolean] 安全なら true
43
+ def safe_method?(method)
44
+ %w[GET HEAD OPTIONS TRACE].include?(method.to_s.upcase)
45
+ end
46
+
47
+ # リクエストから CSRF トークンを抽出
48
+ #
49
+ # @param request [Rack::Request] リクエスト
50
+ # @return [String, nil] トークン
51
+ def extract_token(request)
52
+ # 1. ヘッダーから (JavaScript fetch 用)
53
+ request.env[HEADER_NAME] ||
54
+ # 2. POST パラメータから (HTML フォーム用)
55
+ request.params[PARAM_NAME]
56
+ end
57
+ end
58
+
59
+ # CSRF 保護ミドルウェア
60
+ #
61
+ # @example config.ru
62
+ # use Salvia::CSRF::Protection
63
+ #
64
+ class Protection
65
+ def initialize(app, options = {})
66
+ @app = app
67
+ @options = {
68
+ raise_on_failure: false,
69
+ skip: [], # スキップするパスの配列
70
+ skip_if: nil # スキップ条件の Proc
71
+ }.merge(options)
72
+ end
73
+
74
+ def call(env)
75
+ request = Rack::Request.new(env)
76
+
77
+ # 安全なメソッドはスキップ
78
+ return @app.call(env) if CSRF.safe_method?(request.request_method)
79
+
80
+ # スキップ設定をチェック
81
+ return @app.call(env) if skip_request?(request)
82
+
83
+ # セッションを取得
84
+ session = env["rack.session"]
85
+ unless session
86
+ raise "CSRF protection requires session middleware"
87
+ end
88
+
89
+ # トークンを検証
90
+ token = CSRF.extract_token(request)
91
+ unless CSRF.valid?(session, token)
92
+ return handle_failure(env, request)
93
+ end
94
+
95
+ @app.call(env)
96
+ end
97
+
98
+ private
99
+
100
+ def skip_request?(request)
101
+ # パスによるスキップ
102
+ if @options[:skip].any? { |path| request.path.start_with?(path) }
103
+ return true
104
+ end
105
+
106
+ # カスタム条件によるスキップ
107
+ if @options[:skip_if]&.call(request)
108
+ return true
109
+ end
110
+
111
+ false
112
+ end
113
+
114
+ def handle_failure(env, request)
115
+ if @options[:raise_on_failure]
116
+ raise InvalidTokenError, "CSRF token verification failed"
117
+ end
118
+
119
+ # 403 Forbidden を返す
120
+ [
121
+ 403,
122
+ { "content-type" => "text/plain" },
123
+ ["Forbidden - Invalid CSRF token"]
124
+ ]
125
+ end
126
+ end
127
+
128
+ class InvalidTokenError < StandardError; end
129
+ end
130
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "yaml"
5
+ require "erb"
6
+
7
+ module Salvia
8
+ # ActiveRecord を使用したデータベース接続管理
9
+ #
10
+ # ゼロコンフィグ対応: config/database.yml がなくても動作
11
+ #
12
+ # @example 規約ベースのデフォルト
13
+ # # config/database.yml なしで自動的に以下を使用:
14
+ # # development: db/development.sqlite3
15
+ # # test: db/test.sqlite3
16
+ # # production: DATABASE_URL または db/production.sqlite3
17
+ #
18
+ class Database
19
+ class << self
20
+ # データベース接続をセットアップ
21
+ #
22
+ # @param env [String] 環境名(デフォルト: Salvia.env)
23
+ def setup!(env = nil)
24
+ env ||= Salvia.env
25
+ config = load_config(env)
26
+
27
+ ActiveRecord::Base.establish_connection(config)
28
+
29
+ # 開発環境では SQL をログ出力
30
+ if Salvia.development?
31
+ ActiveRecord::Base.logger = Logger.new($stdout)
32
+ end
33
+ end
34
+
35
+ # config/database.yml からデータベース設定を読み込み
36
+ # ファイルがなければ規約ベースのデフォルトを使用
37
+ #
38
+ # @param env [String] 環境名
39
+ # @return [Hash] データベース設定
40
+ def load_config(env)
41
+ config_path = File.join(Salvia.root, "config", "database.yml")
42
+
43
+ if File.exist?(config_path)
44
+ load_config_from_file(config_path, env)
45
+ else
46
+ default_config(env)
47
+ end
48
+ end
49
+
50
+ # 規約ベースのデフォルト設定
51
+ def default_config(env)
52
+ # 本番環境で DATABASE_URL があればそれを使用
53
+ if env.to_s == "production" && ENV["DATABASE_URL"]
54
+ return { "url" => ENV["DATABASE_URL"] }
55
+ end
56
+
57
+ # SQLite デフォルト
58
+ db_dir = File.join(Salvia.root, "db")
59
+ FileUtils.mkdir_p(db_dir)
60
+
61
+ {
62
+ "adapter" => "sqlite3",
63
+ "database" => File.join(db_dir, "#{env}.sqlite3"),
64
+ "pool" => 5,
65
+ "timeout" => 5000
66
+ }
67
+ end
68
+
69
+ private
70
+
71
+ def load_config_from_file(config_path, env)
72
+ yaml_content = ERB.new(File.read(config_path)).result
73
+ config = YAML.safe_load(yaml_content, aliases: true)
74
+ config[env] || config[env.to_s] || default_config(env)
75
+ end
76
+
77
+ public
78
+
79
+ # データベースを作成
80
+ def create!(env = nil)
81
+ env ||= Salvia.env
82
+ config = load_config(env)
83
+
84
+ case config["adapter"]
85
+ when "sqlite3"
86
+ # SQLite はファイルを自動作成
87
+ db_path = config["database"]
88
+ FileUtils.mkdir_p(File.dirname(db_path)) if db_path != ":memory:"
89
+ puts "データベースを作成しました: #{db_path}"
90
+ when "postgresql"
91
+ create_postgresql_database(config)
92
+ when "mysql2"
93
+ create_mysql_database(config)
94
+ else
95
+ raise Error, "不明なアダプター: #{config['adapter']}"
96
+ end
97
+ end
98
+
99
+ # データベースを削除
100
+ def drop!(env = nil)
101
+ env ||= Salvia.env
102
+ config = load_config(env)
103
+
104
+ case config["adapter"]
105
+ when "sqlite3"
106
+ db_path = config["database"]
107
+ if File.exist?(db_path)
108
+ File.delete(db_path)
109
+ puts "データベースを削除しました: #{db_path}"
110
+ end
111
+ when "postgresql"
112
+ drop_postgresql_database(config)
113
+ when "mysql2"
114
+ drop_mysql_database(config)
115
+ end
116
+ end
117
+
118
+ # 保留中のマイグレーションを実行
119
+ def migrate!
120
+ migrations_path = File.join(Salvia.root, "db", "migrate")
121
+ ActiveRecord::MigrationContext.new(migrations_path).migrate
122
+ end
123
+
124
+ # 直前のマイグレーションをロールバック
125
+ def rollback!(steps = 1)
126
+ migrations_path = File.join(Salvia.root, "db", "migrate")
127
+ ActiveRecord::MigrationContext.new(migrations_path).rollback(steps)
128
+ end
129
+
130
+ # マイグレーションの状態を取得
131
+ def migration_status
132
+ migrations_path = File.join(Salvia.root, "db", "migrate")
133
+ context = ActiveRecord::MigrationContext.new(migrations_path)
134
+
135
+ {
136
+ pending: context.migrations.select { |m| !context.current_version || m.version > context.current_version }.map(&:version),
137
+ current: context.current_version
138
+ }
139
+ end
140
+
141
+ private
142
+
143
+ def create_postgresql_database(config)
144
+ ActiveRecord::Base.establish_connection(config.merge("database" => "postgres"))
145
+ ActiveRecord::Base.connection.create_database(config["database"], config)
146
+ puts "データベースを作成しました: #{config['database']}"
147
+ rescue ActiveRecord::DatabaseAlreadyExists
148
+ puts "データベースは既に存在します: #{config['database']}"
149
+ end
150
+
151
+ def drop_postgresql_database(config)
152
+ ActiveRecord::Base.establish_connection(config.merge("database" => "postgres"))
153
+ ActiveRecord::Base.connection.drop_database(config["database"])
154
+ puts "データベースを削除しました: #{config['database']}"
155
+ rescue ActiveRecord::NoDatabaseError
156
+ puts "データベースが存在しません: #{config['database']}"
157
+ end
158
+
159
+ def create_mysql_database(config)
160
+ ActiveRecord::Base.establish_connection(config.merge("database" => nil))
161
+ ActiveRecord::Base.connection.create_database(config["database"], config)
162
+ puts "データベースを作成しました: #{config['database']}"
163
+ rescue ActiveRecord::DatabaseAlreadyExists
164
+ puts "データベースは既に存在します: #{config['database']}"
165
+ end
166
+
167
+ def drop_mysql_database(config)
168
+ ActiveRecord::Base.establish_connection(config.merge("database" => nil))
169
+ ActiveRecord::Base.connection.drop_database(config["database"])
170
+ puts "データベースを削除しました: #{config['database']}"
171
+ rescue ActiveRecord::NoDatabaseError
172
+ puts "データベースが存在しません: #{config['database']}"
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Salvia
4
+ # シンプルな Flash メッセージ機能
5
+ #
6
+ # 次のリクエストまで保持されるメッセージを管理します。
7
+ # session[:_flash] を使用してデータを保持します。
8
+ #
9
+ # @example
10
+ # flash[:notice] = "保存しました"
11
+ # flash.now[:alert] = "エラーが発生しました"
12
+ class Flash
13
+ def initialize(session)
14
+ @session = session
15
+ @session[:_flash] ||= {}
16
+ @now = @session[:_flash].dup
17
+ @session[:_flash].clear
18
+ end
19
+
20
+ # 次のリクエストまで保持するメッセージを設定/取得
21
+ def [](key)
22
+ @now[key] || @session[:_flash][key]
23
+ end
24
+
25
+ def []=(key, value)
26
+ @session[:_flash][key] = value
27
+ end
28
+
29
+ # 現在のリクエストでのみ有効なメッセージを設定
30
+ def now
31
+ @now
32
+ end
33
+
34
+ # Flash メッセージが空かどうか
35
+ def empty?
36
+ @now.empty? && @session[:_flash].empty?
37
+ end
38
+
39
+ def to_h
40
+ @now.merge(@session[:_flash])
41
+ end
42
+ end
43
+ end