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.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +235 -0
- data/assets/javascripts/islands.js +26 -0
- data/assets/scripts/build_ssr.ts +261 -0
- data/exe/salvia +7 -0
- data/lib/salvia_rb/application.rb +431 -0
- data/lib/salvia_rb/assets.rb +74 -0
- data/lib/salvia_rb/assets_middleware.rb +46 -0
- data/lib/salvia_rb/cli.rb +1398 -0
- data/lib/salvia_rb/component.rb +74 -0
- data/lib/salvia_rb/controller.rb +277 -0
- data/lib/salvia_rb/csrf.rb +130 -0
- data/lib/salvia_rb/database.rb +176 -0
- data/lib/salvia_rb/flash.rb +43 -0
- data/lib/salvia_rb/helpers/component.rb +31 -0
- data/lib/salvia_rb/helpers/csrf.rb +47 -0
- data/lib/salvia_rb/helpers/inspector.rb +192 -0
- data/lib/salvia_rb/helpers/island.rb +143 -0
- data/lib/salvia_rb/helpers/tag.rb +90 -0
- data/lib/salvia_rb/helpers.rb +11 -0
- data/lib/salvia_rb/plugins/base.rb +59 -0
- data/lib/salvia_rb/router.rb +181 -0
- data/lib/salvia_rb/ssr/quickjs.rb +404 -0
- data/lib/salvia_rb/ssr.rb +119 -0
- data/lib/salvia_rb/test.rb +20 -0
- data/lib/salvia_rb/version.rb +5 -0
- data/lib/salvia_rb.rb +250 -0
- metadata +344 -0
|
@@ -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
|