tina4ruby 0.5.2 → 3.0.0
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/CHANGELOG.md +1 -1
- data/README.md +360 -559
- data/exe/{tina4 → tina4ruby} +1 -0
- data/lib/tina4/ai.rb +312 -0
- data/lib/tina4/auth.rb +44 -3
- data/lib/tina4/auto_crud.rb +163 -0
- data/lib/tina4/cli.rb +242 -77
- data/lib/tina4/constants.rb +46 -0
- data/lib/tina4/cors.rb +74 -0
- data/lib/tina4/database/sqlite3_adapter.rb +139 -0
- data/lib/tina4/database.rb +43 -7
- data/lib/tina4/debug.rb +4 -79
- data/lib/tina4/dev_admin.rb +1162 -0
- data/lib/tina4/dev_mailbox.rb +191 -0
- data/lib/tina4/dev_reload.rb +9 -9
- data/lib/tina4/drivers/firebird_driver.rb +19 -3
- data/lib/tina4/drivers/mssql_driver.rb +3 -3
- data/lib/tina4/drivers/mysql_driver.rb +4 -4
- data/lib/tina4/drivers/postgres_driver.rb +9 -2
- data/lib/tina4/drivers/sqlite_driver.rb +1 -1
- data/lib/tina4/env.rb +42 -2
- data/lib/tina4/error_overlay.rb +252 -0
- data/lib/tina4/events.rb +90 -0
- data/lib/tina4/field_types.rb +4 -0
- data/lib/tina4/frond.rb +1336 -0
- data/lib/tina4/gallery/auth/meta.json +1 -0
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
- data/lib/tina4/gallery/database/meta.json +1 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
- data/lib/tina4/gallery/error-overlay/meta.json +1 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
- data/lib/tina4/gallery/orm/meta.json +1 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
- data/lib/tina4/gallery/queue/meta.json +1 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +27 -0
- data/lib/tina4/gallery/rest-api/meta.json +1 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
- data/lib/tina4/gallery/templates/meta.json +1 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
- data/lib/tina4/health.rb +39 -0
- data/lib/tina4/html_element.rb +148 -0
- data/lib/tina4/localization.rb +2 -2
- data/lib/tina4/log.rb +203 -0
- data/lib/tina4/messenger.rb +484 -0
- data/lib/tina4/migration.rb +132 -29
- data/lib/tina4/orm.rb +337 -31
- data/lib/tina4/public/css/tina4.css +178 -1
- data/lib/tina4/public/css/tina4.min.css +1 -2
- data/lib/tina4/public/favicon.ico +0 -0
- data/lib/tina4/public/images/logo.svg +5 -0
- data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
- data/lib/tina4/public/js/frond.min.js +420 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
- data/lib/tina4/public/js/tina4.min.js +93 -0
- data/lib/tina4/public/swagger/index.html +90 -0
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
- data/lib/tina4/queue.rb +40 -4
- data/lib/tina4/queue_backends/lite_backend.rb +88 -0
- data/lib/tina4/rack_app.rb +314 -23
- data/lib/tina4/rate_limiter.rb +123 -0
- data/lib/tina4/request.rb +61 -15
- data/lib/tina4/response.rb +54 -24
- data/lib/tina4/response_cache.rb +134 -0
- data/lib/tina4/router.rb +90 -15
- data/lib/tina4/scss_compiler.rb +2 -2
- data/lib/tina4/seeder.rb +56 -61
- data/lib/tina4/service_runner.rb +303 -0
- data/lib/tina4/session.rb +85 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
- data/lib/tina4/shutdown.rb +84 -0
- data/lib/tina4/sql_translation.rb +295 -0
- data/lib/tina4/template.rb +36 -6
- data/lib/tina4/templates/base.twig +2 -2
- data/lib/tina4/templates/errors/302.twig +14 -0
- data/lib/tina4/templates/errors/401.twig +9 -0
- data/lib/tina4/templates/errors/403.twig +22 -15
- data/lib/tina4/templates/errors/404.twig +22 -15
- data/lib/tina4/templates/errors/500.twig +31 -15
- data/lib/tina4/templates/errors/502.twig +9 -0
- data/lib/tina4/templates/errors/503.twig +12 -0
- data/lib/tina4/templates/errors/base.twig +37 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -18
- data/lib/tina4.rb +57 -21
- metadata +51 -19
- data/lib/tina4/public/js/tina4.js +0 -134
- data/lib/tina4/public/js/tina4helper.js +0 -387
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "base64"
|
|
8
|
+
|
|
9
|
+
module Tina4
|
|
10
|
+
class DevMailbox
|
|
11
|
+
attr_reader :mailbox_dir
|
|
12
|
+
|
|
13
|
+
def initialize(mailbox_dir: nil)
|
|
14
|
+
@mailbox_dir = mailbox_dir || ENV["TINA4_MAILBOX_DIR"] || "data/mailbox"
|
|
15
|
+
ensure_dirs
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Capture an outgoing email to the local filesystem instead of sending
|
|
19
|
+
def capture(to:, subject:, body:, html: false, cc: [], bcc: [],
|
|
20
|
+
reply_to: nil, from_address: nil, from_name: nil, attachments: [])
|
|
21
|
+
msg_id = SecureRandom.uuid
|
|
22
|
+
timestamp = Time.now
|
|
23
|
+
|
|
24
|
+
message = {
|
|
25
|
+
id: msg_id,
|
|
26
|
+
from: { name: from_name, email: from_address },
|
|
27
|
+
to: normalize_recipients(to),
|
|
28
|
+
cc: normalize_recipients(cc),
|
|
29
|
+
bcc: normalize_recipients(bcc),
|
|
30
|
+
reply_to: reply_to,
|
|
31
|
+
subject: subject,
|
|
32
|
+
body: body,
|
|
33
|
+
html: html,
|
|
34
|
+
attachments: store_attachments(msg_id, attachments),
|
|
35
|
+
read: false,
|
|
36
|
+
folder: "outbox",
|
|
37
|
+
created_at: timestamp.iso8601,
|
|
38
|
+
updated_at: timestamp.iso8601
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
write_message(msg_id, message)
|
|
42
|
+
|
|
43
|
+
Tina4::Log.debug("DevMailbox captured email: #{subject} -> #{Array(to).join(', ')}")
|
|
44
|
+
{ success: true, message: "Email captured to dev mailbox", id: msg_id }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# List messages in the mailbox
|
|
48
|
+
def inbox(limit: 50, offset: 0, folder: nil)
|
|
49
|
+
messages = load_all_messages
|
|
50
|
+
messages = messages.select { |m| m[:folder] == folder } if folder
|
|
51
|
+
messages.sort_by { |m| m[:created_at] || "" }.reverse[offset, limit] || []
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Read a single message by ID
|
|
55
|
+
def read(msg_id)
|
|
56
|
+
path = message_path(msg_id)
|
|
57
|
+
return nil unless File.exist?(path)
|
|
58
|
+
|
|
59
|
+
message = JSON.parse(File.read(path), symbolize_names: true)
|
|
60
|
+
unless message[:read]
|
|
61
|
+
message[:read] = true
|
|
62
|
+
message[:updated_at] = Time.now.iso8601
|
|
63
|
+
File.write(path, JSON.pretty_generate(message))
|
|
64
|
+
end
|
|
65
|
+
message
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Count unread messages
|
|
69
|
+
def unread_count
|
|
70
|
+
load_all_messages.count { |m| m[:read] == false }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Delete a message by ID
|
|
74
|
+
def delete(msg_id)
|
|
75
|
+
path = message_path(msg_id)
|
|
76
|
+
return false unless File.exist?(path)
|
|
77
|
+
|
|
78
|
+
File.delete(path)
|
|
79
|
+
# Clean up attachments directory
|
|
80
|
+
att_dir = File.join(@mailbox_dir, "attachments", msg_id)
|
|
81
|
+
FileUtils.rm_rf(att_dir) if Dir.exist?(att_dir)
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Clear all messages, optionally by folder
|
|
86
|
+
def clear(folder: nil)
|
|
87
|
+
if folder
|
|
88
|
+
load_all_messages.each do |msg|
|
|
89
|
+
delete(msg[:id]) if msg[:folder] == folder
|
|
90
|
+
end
|
|
91
|
+
else
|
|
92
|
+
messages_dir = File.join(@mailbox_dir, "messages")
|
|
93
|
+
FileUtils.rm_rf(messages_dir)
|
|
94
|
+
FileUtils.rm_rf(File.join(@mailbox_dir, "attachments"))
|
|
95
|
+
ensure_dirs
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Seed the mailbox with sample messages for development
|
|
100
|
+
def seed(count: 5)
|
|
101
|
+
fake = Tina4::FakeData.new
|
|
102
|
+
count.times do |i|
|
|
103
|
+
name = fake.name
|
|
104
|
+
email = fake.email(from_name: name)
|
|
105
|
+
capture(
|
|
106
|
+
to: "dev@localhost",
|
|
107
|
+
subject: fake.sentence(words: 4 + rand(4)),
|
|
108
|
+
body: Array.new(2 + rand(3)) { fake.sentence(words: 8 + rand(8)) }.join("\n\n"),
|
|
109
|
+
html: i.even?,
|
|
110
|
+
from_address: email,
|
|
111
|
+
from_name: name
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
Tina4::Log.info("DevMailbox seeded with #{count} messages")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Count messages by folder
|
|
118
|
+
# Returns { inbox: N, outbox: N, total: N }
|
|
119
|
+
def count(folder: nil)
|
|
120
|
+
messages = load_all_messages
|
|
121
|
+
if folder
|
|
122
|
+
n = messages.count { |m| m[:folder] == folder }
|
|
123
|
+
{ folder.to_sym => n, total: n }
|
|
124
|
+
else
|
|
125
|
+
inbox_count = messages.count { |m| m[:folder] == "inbox" }
|
|
126
|
+
outbox_count = messages.count { |m| m[:folder] == "outbox" }
|
|
127
|
+
{ inbox: inbox_count, outbox: outbox_count, total: messages.length }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def ensure_dirs
|
|
134
|
+
FileUtils.mkdir_p(File.join(@mailbox_dir, "messages"))
|
|
135
|
+
FileUtils.mkdir_p(File.join(@mailbox_dir, "attachments"))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def message_path(msg_id)
|
|
139
|
+
File.join(@mailbox_dir, "messages", "#{msg_id}.json")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def write_message(msg_id, message)
|
|
143
|
+
File.write(message_path(msg_id), JSON.pretty_generate(message))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def load_all_messages
|
|
147
|
+
pattern = File.join(@mailbox_dir, "messages", "*.json")
|
|
148
|
+
Dir.glob(pattern).filter_map do |path|
|
|
149
|
+
JSON.parse(File.read(path), symbolize_names: true)
|
|
150
|
+
rescue JSON::ParserError => e
|
|
151
|
+
Tina4::Log.error("DevMailbox: corrupt message file #{path}: #{e.message}")
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def normalize_recipients(value)
|
|
157
|
+
case value
|
|
158
|
+
when nil then []
|
|
159
|
+
when String then [value]
|
|
160
|
+
when Array then value.flatten.compact
|
|
161
|
+
else [value.to_s]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def store_attachments(msg_id, attachments)
|
|
166
|
+
return [] if attachments.nil? || attachments.empty?
|
|
167
|
+
|
|
168
|
+
att_dir = File.join(@mailbox_dir, "attachments", msg_id)
|
|
169
|
+
FileUtils.mkdir_p(att_dir)
|
|
170
|
+
|
|
171
|
+
attachments.map do |attachment|
|
|
172
|
+
if attachment.is_a?(Hash)
|
|
173
|
+
filename = attachment[:filename] || attachment[:name] || "attachment"
|
|
174
|
+
content = attachment[:content] || ""
|
|
175
|
+
mime = attachment[:mime_type] || attachment[:content_type] || "application/octet-stream"
|
|
176
|
+
elsif attachment.is_a?(String) && File.exist?(attachment)
|
|
177
|
+
filename = File.basename(attachment)
|
|
178
|
+
content = File.binread(attachment)
|
|
179
|
+
mime = "application/octet-stream"
|
|
180
|
+
else
|
|
181
|
+
next nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
file_path = File.join(att_dir, filename)
|
|
185
|
+
File.binwrite(file_path, content)
|
|
186
|
+
|
|
187
|
+
{ filename: filename, mime_type: mime, size: content.bytesize, path: file_path }
|
|
188
|
+
end.compact
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
data/lib/tina4/dev_reload.rb
CHANGED
|
@@ -17,25 +17,25 @@ module Tina4
|
|
|
17
17
|
# Also watch root for .rb files
|
|
18
18
|
dirs << root_dir
|
|
19
19
|
|
|
20
|
-
Tina4::
|
|
20
|
+
Tina4::Log.info("Dev reload watching: #{dirs.join(', ')}")
|
|
21
21
|
|
|
22
22
|
@listener = Listen.to(*dirs, only: /\.(#{WATCH_EXTENSIONS.map { |e| e.delete('.') }.join('|')})$/, ignore: build_ignore_regex) do |modified, added, removed|
|
|
23
23
|
changes = { modified: modified, added: added, removed: removed }
|
|
24
24
|
all_files = modified + added + removed
|
|
25
25
|
next if all_files.empty?
|
|
26
26
|
|
|
27
|
-
Tina4::
|
|
28
|
-
modified.each { |f| Tina4::
|
|
29
|
-
added.each { |f| Tina4::
|
|
30
|
-
removed.each { |f| Tina4::
|
|
27
|
+
Tina4::Log.info("File changes detected:")
|
|
28
|
+
modified.each { |f| Tina4::Log.debug(" Modified: #{f}") }
|
|
29
|
+
added.each { |f| Tina4::Log.debug(" Added: #{f}") }
|
|
30
|
+
removed.each { |f| Tina4::Log.debug(" Removed: #{f}") }
|
|
31
31
|
|
|
32
32
|
# Reload Ruby files
|
|
33
33
|
modified.select { |f| f.end_with?(".rb") }.each do |file|
|
|
34
34
|
begin
|
|
35
35
|
load file
|
|
36
|
-
Tina4::
|
|
36
|
+
Tina4::Log.info("Reloaded: #{file}")
|
|
37
37
|
rescue => e
|
|
38
|
-
Tina4::
|
|
38
|
+
Tina4::Log.error("Reload failed: #{file} - #{e.message}")
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
|
|
@@ -49,12 +49,12 @@ module Tina4
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
@listener.start
|
|
52
|
-
Tina4::
|
|
52
|
+
Tina4::Log.info("Dev reload started")
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def stop
|
|
56
56
|
@listener&.stop
|
|
57
|
-
Tina4::
|
|
57
|
+
Tina4::Log.info("Dev reload stopped")
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
private
|
|
@@ -5,10 +5,26 @@ module Tina4
|
|
|
5
5
|
class FirebirdDriver
|
|
6
6
|
attr_reader :connection
|
|
7
7
|
|
|
8
|
-
def connect(connection_string)
|
|
8
|
+
def connect(connection_string, username: nil, password: nil)
|
|
9
9
|
require "fb"
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
require "uri"
|
|
11
|
+
uri = URI.parse(connection_string)
|
|
12
|
+
host = uri.host
|
|
13
|
+
port = uri.port || 3050
|
|
14
|
+
db_path = uri.path&.sub(/^\//, "")
|
|
15
|
+
db_user = username || uri.user
|
|
16
|
+
db_pass = password || uri.password
|
|
17
|
+
|
|
18
|
+
database = if host
|
|
19
|
+
"#{host}/#{port}:#{db_path}"
|
|
20
|
+
else
|
|
21
|
+
db_path || connection_string.sub(/^firebird:\/\//, "")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
opts = { database: database }
|
|
25
|
+
opts[:username] = db_user if db_user
|
|
26
|
+
opts[:password] = db_pass if db_pass
|
|
27
|
+
@connection = Fb::Database.new(**opts).connect
|
|
12
28
|
rescue LoadError
|
|
13
29
|
raise "Firebird driver requires the 'fb' gem. Install it with: gem install fb"
|
|
14
30
|
end
|
|
@@ -5,14 +5,14 @@ module Tina4
|
|
|
5
5
|
class MssqlDriver
|
|
6
6
|
attr_reader :connection
|
|
7
7
|
|
|
8
|
-
def connect(connection_string)
|
|
8
|
+
def connect(connection_string, username: nil, password: nil)
|
|
9
9
|
require "tiny_tds"
|
|
10
10
|
uri = parse_connection(connection_string)
|
|
11
11
|
@connection = TinyTds::Client.new(
|
|
12
12
|
host: uri[:host],
|
|
13
13
|
port: uri[:port] || 1433,
|
|
14
|
-
username: uri[:username],
|
|
15
|
-
password: uri[:password],
|
|
14
|
+
username: username || uri[:username],
|
|
15
|
+
password: password || uri[:password],
|
|
16
16
|
database: uri[:database]
|
|
17
17
|
)
|
|
18
18
|
end
|
|
@@ -5,14 +5,14 @@ module Tina4
|
|
|
5
5
|
class MysqlDriver
|
|
6
6
|
attr_reader :connection
|
|
7
7
|
|
|
8
|
-
def connect(connection_string)
|
|
8
|
+
def connect(connection_string, username: nil, password: nil)
|
|
9
9
|
require "mysql2"
|
|
10
|
-
uri = URI.parse(connection_string
|
|
10
|
+
uri = URI.parse(connection_string)
|
|
11
11
|
@connection = Mysql2::Client.new(
|
|
12
12
|
host: uri.host || "localhost",
|
|
13
13
|
port: uri.port || 3306,
|
|
14
|
-
username: uri.user,
|
|
15
|
-
password: uri.password,
|
|
14
|
+
username: username || uri.user,
|
|
15
|
+
password: password || uri.password,
|
|
16
16
|
database: uri.path&.sub("/", "")
|
|
17
17
|
)
|
|
18
18
|
end
|
|
@@ -5,9 +5,16 @@ module Tina4
|
|
|
5
5
|
class PostgresDriver
|
|
6
6
|
attr_reader :connection
|
|
7
7
|
|
|
8
|
-
def connect(connection_string)
|
|
8
|
+
def connect(connection_string, username: nil, password: nil)
|
|
9
9
|
require "pg"
|
|
10
|
-
|
|
10
|
+
url = connection_string
|
|
11
|
+
if username || password
|
|
12
|
+
uri = URI.parse(url)
|
|
13
|
+
uri.user = username if username
|
|
14
|
+
uri.password = password if password
|
|
15
|
+
url = uri.to_s
|
|
16
|
+
end
|
|
17
|
+
@connection = PG.connect(url)
|
|
11
18
|
end
|
|
12
19
|
|
|
13
20
|
def close
|
|
@@ -5,7 +5,7 @@ module Tina4
|
|
|
5
5
|
class SqliteDriver
|
|
6
6
|
attr_reader :connection
|
|
7
7
|
|
|
8
|
-
def connect(connection_string)
|
|
8
|
+
def connect(connection_string, username: nil, password: nil)
|
|
9
9
|
require "sqlite3"
|
|
10
10
|
db_path = connection_string.sub(/^sqlite:\/\//, "").sub(/^sqlite:/, "")
|
|
11
11
|
@connection = SQLite3::Database.new(db_path)
|
data/lib/tina4/env.rb
CHANGED
|
@@ -7,12 +7,21 @@ module Tina4
|
|
|
7
7
|
"PROJECT_NAME" => "Tina4 Ruby Project",
|
|
8
8
|
"VERSION" => "1.0.0",
|
|
9
9
|
"TINA4_LANGUAGE" => "en",
|
|
10
|
-
"
|
|
10
|
+
"TINA4_DEBUG" => "true",
|
|
11
|
+
"TINA4_LOG_LEVEL" => "[TINA4_LOG_ALL]",
|
|
11
12
|
"SECRET" => "tina4-secret-change-me"
|
|
12
13
|
}.freeze
|
|
13
14
|
|
|
15
|
+
# Check if a value is truthy for env boolean checks.
|
|
16
|
+
#
|
|
17
|
+
# Accepts: "true", "True", "TRUE", "1", "yes", "Yes", "YES", "on", "On", "ON".
|
|
18
|
+
# Everything else is falsy (including empty string, nil, not set).
|
|
19
|
+
def self.truthy?(val)
|
|
20
|
+
%w[true 1 yes on].include?(val.to_s.strip.downcase)
|
|
21
|
+
end
|
|
22
|
+
|
|
14
23
|
class << self
|
|
15
|
-
def
|
|
24
|
+
def load_env(root_dir = Dir.pwd)
|
|
16
25
|
env_file = resolve_env_file(root_dir)
|
|
17
26
|
unless File.exist?(env_file)
|
|
18
27
|
create_default_env(env_file)
|
|
@@ -20,6 +29,35 @@ module Tina4
|
|
|
20
29
|
parse_env_file(env_file)
|
|
21
30
|
end
|
|
22
31
|
|
|
32
|
+
# Get an env var value, with optional default
|
|
33
|
+
def get_env(key, default = nil)
|
|
34
|
+
ENV[key.to_s] || default
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Check if an env var exists
|
|
38
|
+
def has_env?(key)
|
|
39
|
+
ENV.key?(key.to_s)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Return all current ENV vars as a hash
|
|
43
|
+
def all_env
|
|
44
|
+
ENV.to_h
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Raise if any of the given keys are missing from ENV
|
|
48
|
+
def require_env!(*keys)
|
|
49
|
+
missing = keys.map(&:to_s).reject { |k| ENV.key?(k) }
|
|
50
|
+
unless missing.empty?
|
|
51
|
+
raise KeyError, "Missing required env vars: #{missing.join(', ')}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Reset: clear all env vars that were loaded (restore to process defaults)
|
|
56
|
+
def reset_env
|
|
57
|
+
@loaded_keys&.each { |k| ENV.delete(k) }
|
|
58
|
+
@loaded_keys = []
|
|
59
|
+
end
|
|
60
|
+
|
|
23
61
|
private
|
|
24
62
|
|
|
25
63
|
def resolve_env_file(root_dir)
|
|
@@ -47,6 +85,8 @@ module Tina4
|
|
|
47
85
|
key = match[1]
|
|
48
86
|
value = match[2].gsub(/["']\z/, "")
|
|
49
87
|
ENV[key] ||= value
|
|
88
|
+
@loaded_keys ||= []
|
|
89
|
+
@loaded_keys << key
|
|
50
90
|
end
|
|
51
91
|
end
|
|
52
92
|
end
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Tina4 Debug — Rich error overlay for development mode.
|
|
4
|
+
#
|
|
5
|
+
# Renders a professional, syntax-highlighted HTML error page when an unhandled
|
|
6
|
+
# exception occurs in a route handler.
|
|
7
|
+
#
|
|
8
|
+
# begin
|
|
9
|
+
# handler.call(request, response)
|
|
10
|
+
# rescue => e
|
|
11
|
+
# Tina4::ErrorOverlay.render(e, request: env)
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Only activate when TINA4_DEBUG is true.
|
|
15
|
+
# In production, call Tina4::ErrorOverlay.render_production instead.
|
|
16
|
+
|
|
17
|
+
module Tina4
|
|
18
|
+
module ErrorOverlay
|
|
19
|
+
# ── Colour palette (Catppuccin Mocha) ──────────────────────────────
|
|
20
|
+
BG = "#1e1e2e"
|
|
21
|
+
SURFACE = "#313244"
|
|
22
|
+
OVERLAY_COLOR = "#45475a"
|
|
23
|
+
TEXT_COLOR = "#cdd6f4"
|
|
24
|
+
SUBTEXT = "#a6adc8"
|
|
25
|
+
RED = "#f38ba8"
|
|
26
|
+
YELLOW = "#f9e2af"
|
|
27
|
+
BLUE = "#89b4fa"
|
|
28
|
+
GREEN = "#a6e3a1"
|
|
29
|
+
LAVENDER = "#b4befe"
|
|
30
|
+
PEACH = "#fab387"
|
|
31
|
+
ERROR_LINE_BG = "rgba(243,139,168,0.15)"
|
|
32
|
+
|
|
33
|
+
CONTEXT_LINES = 7
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# Render a rich HTML error overlay.
|
|
37
|
+
#
|
|
38
|
+
# @param exception [Exception] the caught exception
|
|
39
|
+
# @param request [Hash, nil] optional request details (Rack env or custom hash)
|
|
40
|
+
# @return [String] complete HTML page
|
|
41
|
+
def render(exception, request: nil)
|
|
42
|
+
exc_type = exception.class.name
|
|
43
|
+
exc_msg = exception.message
|
|
44
|
+
|
|
45
|
+
# ── Stack trace ──
|
|
46
|
+
frames_html = +""
|
|
47
|
+
backtrace = exception.backtrace || []
|
|
48
|
+
backtrace.each do |line|
|
|
49
|
+
file, lineno, method = parse_backtrace_line(line)
|
|
50
|
+
frames_html << format_frame(file, lineno, method)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# ── Request info ──
|
|
54
|
+
request_pairs = []
|
|
55
|
+
if request.is_a?(Hash)
|
|
56
|
+
request.each do |k, v|
|
|
57
|
+
key = k.to_s
|
|
58
|
+
if v.is_a?(Hash)
|
|
59
|
+
v.each { |hk, hv| request_pairs << ["#{key}.#{hk}", hv.to_s] }
|
|
60
|
+
elsif key.start_with?("HTTP_") || %w[REQUEST_METHOD REQUEST_URI SERVER_PROTOCOL
|
|
61
|
+
REMOTE_ADDR SERVER_PORT QUERY_STRING CONTENT_TYPE CONTENT_LENGTH
|
|
62
|
+
method url path].include?(key)
|
|
63
|
+
request_pairs << [key, v.to_s]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
request_section = request_pairs.empty? ? "" : collapsible("Request Details", table(request_pairs))
|
|
68
|
+
|
|
69
|
+
# ── Environment ──
|
|
70
|
+
env_pairs = [
|
|
71
|
+
["Framework", "Tina4 Ruby"],
|
|
72
|
+
["Version", defined?(Tina4::VERSION) ? Tina4::VERSION : "unknown"],
|
|
73
|
+
["Ruby", RUBY_VERSION],
|
|
74
|
+
["Platform", RUBY_PLATFORM],
|
|
75
|
+
["Debug", ENV.fetch("TINA4_DEBUG", "false")],
|
|
76
|
+
["Log Level", ENV.fetch("TINA4_LOG_LEVEL", "ERROR")]
|
|
77
|
+
]
|
|
78
|
+
env_section = collapsible("Environment", table(env_pairs))
|
|
79
|
+
stack_section = collapsible("Stack Trace", frames_html, open_by_default: true)
|
|
80
|
+
|
|
81
|
+
<<~HTML
|
|
82
|
+
<!DOCTYPE html>
|
|
83
|
+
<html lang="en">
|
|
84
|
+
<head>
|
|
85
|
+
<meta charset="utf-8">
|
|
86
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
87
|
+
<title>Tina4 Error — #{esc(exc_type)}</title>
|
|
88
|
+
<style>
|
|
89
|
+
*{margin:0;padding:0;box-sizing:border-box;}
|
|
90
|
+
body{background:#{BG};color:#{TEXT_COLOR};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px;line-height:1.5;}
|
|
91
|
+
</style>
|
|
92
|
+
</head>
|
|
93
|
+
<body>
|
|
94
|
+
<div style="max-width:960px;margin:0 auto;">
|
|
95
|
+
<div style="margin-bottom:24px;">
|
|
96
|
+
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
|
|
97
|
+
<span style="background:#{RED};color:#{BG};padding:4px 12px;border-radius:4px;font-weight:700;font-size:13px;text-transform:uppercase;">Error</span>
|
|
98
|
+
<span style="color:#{SUBTEXT};font-size:14px;">Tina4 Debug Overlay</span>
|
|
99
|
+
</div>
|
|
100
|
+
<h1 style="color:#{RED};font-size:28px;font-weight:700;margin-bottom:8px;">#{esc(exc_type)}</h1>
|
|
101
|
+
<p style="color:#{TEXT_COLOR};font-size:18px;font-family:'SF Mono','Fira Code','Consolas',monospace;background:#{SURFACE};padding:12px 16px;border-radius:6px;border-left:4px solid #{RED};">#{esc(exc_msg)}</p>
|
|
102
|
+
</div>
|
|
103
|
+
#{stack_section}
|
|
104
|
+
#{request_section}
|
|
105
|
+
#{env_section}
|
|
106
|
+
<div style="margin-top:32px;padding-top:16px;border-top:1px solid #{OVERLAY_COLOR};color:#{SUBTEXT};font-size:12px;">
|
|
107
|
+
Tina4 Debug Overlay — This page is only shown in debug mode. Set TINA4_DEBUG=false in production.
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</body>
|
|
111
|
+
</html>
|
|
112
|
+
HTML
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Render a safe, generic error page for production.
|
|
116
|
+
def render_production(status_code: 500, message: "Internal Server Error", path: "")
|
|
117
|
+
# Determine color based on status code
|
|
118
|
+
code_color = case status_code
|
|
119
|
+
when 403 then "#f59e0b"
|
|
120
|
+
when 404 then "#3b82f6"
|
|
121
|
+
else "#ef4444"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
<<~HTML
|
|
125
|
+
<!DOCTYPE html>
|
|
126
|
+
<html lang="en">
|
|
127
|
+
<head>
|
|
128
|
+
<meta charset="utf-8">
|
|
129
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
130
|
+
<title>#{status_code} — #{esc(message)}</title>
|
|
131
|
+
<style>
|
|
132
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
133
|
+
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
134
|
+
.error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
|
|
135
|
+
.error-code { font-size: 8rem; font-weight: 900; color: #{code_color}; opacity: 0.6; line-height: 1; margin-bottom: 0.5rem; }
|
|
136
|
+
.error-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.75rem; }
|
|
137
|
+
.error-msg { color: #94a3b8; font-size: 1rem; margin-bottom: 1.5rem; line-height: 1.5; }
|
|
138
|
+
.error-path { font-family: 'SF Mono', monospace; background: #0f172a; color: #{code_color}; padding: 0.5rem 1rem; border-radius: 0.5rem; font-size: 0.85rem; word-break: break-all; margin-bottom: 1.5rem; display: inline-block; }
|
|
139
|
+
.error-home { display: inline-block; padding: 0.6rem 2rem; background: #3b82f6; color: #fff; text-decoration: none; border-radius: 0.5rem; font-size: 0.9rem; font-weight: 600; }
|
|
140
|
+
.error-home:hover { opacity: 0.9; }
|
|
141
|
+
.logo { font-size: 1.5rem; margin-bottom: 1rem; opacity: 0.5; }
|
|
142
|
+
</style>
|
|
143
|
+
</head>
|
|
144
|
+
<body>
|
|
145
|
+
<div class="error-card">
|
|
146
|
+
<div class="error-code">#{status_code}</div>
|
|
147
|
+
<div class="error-title">#{esc(message)}</div>
|
|
148
|
+
<div class="error-msg">Something went wrong while processing your request.</div>
|
|
149
|
+
#{path.to_s.empty? ? '' : "<div class=\"error-path\">#{esc(path)}</div><br>"}
|
|
150
|
+
<a href="/" class="error-home">Go Home</a>
|
|
151
|
+
</div>
|
|
152
|
+
</body>
|
|
153
|
+
</html>
|
|
154
|
+
HTML
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Return true if TINA4_DEBUG is enabled.
|
|
158
|
+
def debug_mode?
|
|
159
|
+
Tina4::Env.truthy?(ENV.fetch("TINA4_DEBUG", ""))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def esc(text)
|
|
165
|
+
text.to_s
|
|
166
|
+
.gsub("&", "&")
|
|
167
|
+
.gsub("<", "<")
|
|
168
|
+
.gsub(">", ">")
|
|
169
|
+
.gsub('"', """)
|
|
170
|
+
.gsub("'", "'")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def parse_backtrace_line(line)
|
|
174
|
+
if line =~ /\A(.+):(\d+):in [`'](.+)'\z/
|
|
175
|
+
[$1, $2.to_i, $3]
|
|
176
|
+
elsif line =~ /\A(.+):(\d+)\z/
|
|
177
|
+
[$1, $2.to_i, "{main}"]
|
|
178
|
+
else
|
|
179
|
+
[line, 0, "{unknown}"]
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def read_source_lines(filename, lineno)
|
|
184
|
+
return [] unless filename && lineno.positive? && File.file?(filename) && File.readable?(filename)
|
|
185
|
+
|
|
186
|
+
all_lines = File.readlines(filename, chomp: true)
|
|
187
|
+
start_idx = [0, lineno - CONTEXT_LINES - 1].max
|
|
188
|
+
end_idx = [all_lines.length, lineno + CONTEXT_LINES].min
|
|
189
|
+
(start_idx...end_idx).map do |i|
|
|
190
|
+
num = i + 1
|
|
191
|
+
[num, all_lines[i] || "", num == lineno]
|
|
192
|
+
end
|
|
193
|
+
rescue StandardError
|
|
194
|
+
[]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def format_source_block(filename, lineno)
|
|
198
|
+
lines = read_source_lines(filename, lineno)
|
|
199
|
+
return "" if lines.empty?
|
|
200
|
+
|
|
201
|
+
rows = lines.map do |num, text, is_error|
|
|
202
|
+
bg = is_error ? "background:#{ERROR_LINE_BG};" : ""
|
|
203
|
+
marker = is_error ? "▶" : " "
|
|
204
|
+
"<div style=\"#{bg}display:flex;padding:1px 0;\">" \
|
|
205
|
+
"<span style=\"color:#{YELLOW};min-width:3.5em;text-align:right;padding-right:1em;user-select:none;\">#{num}</span>" \
|
|
206
|
+
"<span style=\"color:#{RED};width:1.2em;user-select:none;\">#{marker}</span>" \
|
|
207
|
+
"<span style=\"color:#{TEXT_COLOR};white-space:pre-wrap;tab-size:4;\">#{esc(text)}</span>" \
|
|
208
|
+
"</div>"
|
|
209
|
+
end.join("\n")
|
|
210
|
+
|
|
211
|
+
"<div style=\"background:#{SURFACE};border-radius:6px;padding:12px;overflow-x:auto;" \
|
|
212
|
+
"font-family:'SF Mono','Fira Code','Consolas',monospace;font-size:13px;line-height:1.6;\">" \
|
|
213
|
+
"#{rows}</div>"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def format_frame(filename, lineno, func_name)
|
|
217
|
+
source = (filename && lineno.positive?) ? format_source_block(filename, lineno) : ""
|
|
218
|
+
"<div style=\"margin-bottom:16px;\">" \
|
|
219
|
+
"<div style=\"margin-bottom:4px;\">" \
|
|
220
|
+
"<span style=\"color:#{BLUE};\">#{esc(filename.to_s)}</span>" \
|
|
221
|
+
"<span style=\"color:#{SUBTEXT};\"> : </span>" \
|
|
222
|
+
"<span style=\"color:#{YELLOW};\">#{lineno}</span>" \
|
|
223
|
+
"<span style=\"color:#{SUBTEXT};\"> in </span>" \
|
|
224
|
+
"<span style=\"color:#{GREEN};\">#{esc(func_name.to_s)}</span>" \
|
|
225
|
+
"</div>" \
|
|
226
|
+
"#{source}" \
|
|
227
|
+
"</div>"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def collapsible(title, content, open_by_default: false)
|
|
231
|
+
open_attr = open_by_default ? " open" : ""
|
|
232
|
+
"<details style=\"margin-top:16px;\"#{open_attr}>" \
|
|
233
|
+
"<summary style=\"cursor:pointer;color:#{LAVENDER};font-weight:600;font-size:15px;" \
|
|
234
|
+
"padding:8px 0;user-select:none;\">#{esc(title)}</summary>" \
|
|
235
|
+
"<div style=\"padding:8px 0;\">#{content}</div>" \
|
|
236
|
+
"</details>"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def table(pairs)
|
|
240
|
+
return "<span style=\"color:#{SUBTEXT};\">None</span>" if pairs.empty?
|
|
241
|
+
|
|
242
|
+
rows = pairs.map do |key, val|
|
|
243
|
+
"<tr>" \
|
|
244
|
+
"<td style=\"color:#{PEACH};padding:4px 16px 4px 0;vertical-align:top;white-space:nowrap;\">#{esc(key)}</td>" \
|
|
245
|
+
"<td style=\"color:#{TEXT_COLOR};padding:4px 0;word-break:break-all;\">#{esc(val)}</td>" \
|
|
246
|
+
"</tr>"
|
|
247
|
+
end.join
|
|
248
|
+
"<table style=\"border-collapse:collapse;width:100%;\">#{rows}</table>"
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|