tina4ruby 3.11.36 → 3.12.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/lib/tina4/auth.rb +5 -5
- data/lib/tina4/constants.rb +40 -0
- data/lib/tina4/container.rb +1 -1
- data/lib/tina4/database.rb +6 -6
- data/lib/tina4/dev_admin.rb +1 -1
- data/lib/tina4/drivers/postgres_driver.rb +38 -4
- data/lib/tina4/env.rb +74 -3
- data/lib/tina4/field_types.rb +1 -1
- data/lib/tina4/mcp.rb +1 -1
- data/lib/tina4/messenger.rb +13 -14
- data/lib/tina4/orm.rb +28 -0
- data/lib/tina4/public/js/frond.js +600 -0
- data/lib/tina4/public/js/frond.min.js +1 -1
- data/lib/tina4/rack_app.rb +52 -15
- data/lib/tina4/session.rb +1 -1
- data/lib/tina4/session_handlers/database_handler.rb +1 -1
- data/lib/tina4/swagger.rb +3 -3
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +3 -0
- data/lib/tina4.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 271f281382fed50cadd4ca76a86ecdd5273f5b9f8e80ed721d147e75aa197704
|
|
4
|
+
data.tar.gz: 10b1b39f0dda79f7fe5af9b42a4903a2d2c4d93e3331b7f7b246a0e890b85ca6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca638ffaba48b33c56a138509991bc3beed2b32cbeb63d4705cefac22b90a63563c6dc24835c71d82eed52b330e46c47953d738d28f1daf9ae5b214ce86ce8ef
|
|
7
|
+
data.tar.gz: 86a81881255a911e474e662c1b069c50bbf7fc58b1d336d6c36c08c1f5f870a46ee50681879e1248c869317dcf3a80bbccd8694821c8a39e17d19874f413c09c
|
data/lib/tina4/auth.rb
CHANGED
|
@@ -19,7 +19,7 @@ module Tina4
|
|
|
19
19
|
|
|
20
20
|
# Returns true when SECRET env var is set and no RSA keys exist in .keys/
|
|
21
21
|
def use_hmac?
|
|
22
|
-
secret = ENV["
|
|
22
|
+
secret = ENV["TINA4_SECRET"]
|
|
23
23
|
return false if secret.nil? || secret.empty?
|
|
24
24
|
|
|
25
25
|
# If RSA keys already exist on disk, prefer RS256 for backward compat
|
|
@@ -29,7 +29,7 @@ module Tina4
|
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def hmac_secret
|
|
32
|
-
ENV["
|
|
32
|
+
ENV["TINA4_SECRET"]
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
# Base64url-encode without padding (JWT spec)
|
|
@@ -191,7 +191,7 @@ module Tina4
|
|
|
191
191
|
token = Regexp.last_match(1)
|
|
192
192
|
|
|
193
193
|
# API_KEY bypass — matches tina4_python behavior
|
|
194
|
-
api_key = ENV["TINA4_API_KEY"]
|
|
194
|
+
api_key = ENV["TINA4_API_KEY"]
|
|
195
195
|
if api_key && !api_key.empty? && token == api_key
|
|
196
196
|
return { "api_key" => true }
|
|
197
197
|
end
|
|
@@ -206,7 +206,7 @@ module Tina4
|
|
|
206
206
|
end
|
|
207
207
|
|
|
208
208
|
def validate_api_key(provided, expected: nil)
|
|
209
|
-
expected ||= ENV["TINA4_API_KEY"]
|
|
209
|
+
expected ||= ENV["TINA4_API_KEY"]
|
|
210
210
|
return false if expected.nil? || expected.empty?
|
|
211
211
|
return false if provided.nil? || provided.empty?
|
|
212
212
|
return false if provided.length != expected.length
|
|
@@ -230,7 +230,7 @@ module Tina4
|
|
|
230
230
|
token = Regexp.last_match(1)
|
|
231
231
|
|
|
232
232
|
# API_KEY bypass — matches tina4_python behavior
|
|
233
|
-
api_key = ENV["TINA4_API_KEY"]
|
|
233
|
+
api_key = ENV["TINA4_API_KEY"]
|
|
234
234
|
if api_key && !api_key.empty? && token == api_key
|
|
235
235
|
env["tina4.auth"] = { "api_key" => true }
|
|
236
236
|
return true
|
data/lib/tina4/constants.rb
CHANGED
|
@@ -43,4 +43,44 @@ module Tina4
|
|
|
43
43
|
TEXT_PLAIN = "text/plain; charset=utf-8"
|
|
44
44
|
TEXT_CSV = "text/csv"
|
|
45
45
|
TEXT_XML = "text/xml"
|
|
46
|
+
|
|
47
|
+
# ── HTTP Reason Phrases (RFC 7231 / RFC 9110) ──
|
|
48
|
+
#
|
|
49
|
+
# Used to write a correct HTTP/1.1 status line wherever the framework
|
|
50
|
+
# emits one manually. Previously code paths that built the status line
|
|
51
|
+
# by hand wrote "HTTP/1.1 404 OK" regardless of code, which is
|
|
52
|
+
# malformed. ``Tina4.http_reason(status)`` always returns a non-empty
|
|
53
|
+
# phrase that matches the status family.
|
|
54
|
+
HTTP_REASON_PHRASES = {
|
|
55
|
+
100 => "Continue", 101 => "Switching Protocols",
|
|
56
|
+
200 => "OK", 201 => "Created", 202 => "Accepted", 204 => "No Content",
|
|
57
|
+
206 => "Partial Content",
|
|
58
|
+
301 => "Moved Permanently", 302 => "Found", 303 => "See Other",
|
|
59
|
+
304 => "Not Modified", 307 => "Temporary Redirect", 308 => "Permanent Redirect",
|
|
60
|
+
400 => "Bad Request", 401 => "Unauthorized", 403 => "Forbidden",
|
|
61
|
+
404 => "Not Found", 405 => "Method Not Allowed", 406 => "Not Acceptable",
|
|
62
|
+
409 => "Conflict", 410 => "Gone", 413 => "Content Too Large",
|
|
63
|
+
415 => "Unsupported Media Type", 422 => "Unprocessable Content",
|
|
64
|
+
429 => "Too Many Requests",
|
|
65
|
+
500 => "Internal Server Error", 501 => "Not Implemented",
|
|
66
|
+
502 => "Bad Gateway", 503 => "Service Unavailable", 504 => "Gateway Timeout"
|
|
67
|
+
}.freeze
|
|
68
|
+
|
|
69
|
+
# Return the canonical HTTP reason phrase for ``status``.
|
|
70
|
+
#
|
|
71
|
+
# Falls back to a sensible label when an exotic status is used. Never
|
|
72
|
+
# returns an empty string — the HTTP/1.1 status line requires a phrase.
|
|
73
|
+
# Prefers Rack::Utils::HTTP_STATUS_CODES when Rack is available so the
|
|
74
|
+
# phrase tracks Rack's mapping, otherwise uses the local table above.
|
|
75
|
+
def self.http_reason(status)
|
|
76
|
+
code = status.to_i
|
|
77
|
+
if defined?(Rack::Utils::HTTP_STATUS_CODES)
|
|
78
|
+
phrase = Rack::Utils::HTTP_STATUS_CODES[code]
|
|
79
|
+
return phrase if phrase && !phrase.empty?
|
|
80
|
+
end
|
|
81
|
+
phrase = HTTP_REASON_PHRASES[code]
|
|
82
|
+
return phrase if phrase && !phrase.empty?
|
|
83
|
+
return "OK" if code >= 200 && code < 300
|
|
84
|
+
"Error"
|
|
85
|
+
end
|
|
46
86
|
end
|
data/lib/tina4/container.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Tina4
|
|
|
4
4
|
# Lightweight dependency injection container.
|
|
5
5
|
#
|
|
6
6
|
# Tina4::Container.register(:mailer) { MailService.new } # transient — new instance each get
|
|
7
|
-
# Tina4::Container.singleton(:db) { Database.new(ENV["
|
|
7
|
+
# Tina4::Container.singleton(:db) { Database.new(ENV["TINA4_DATABASE_URL"]) } # singleton — memoised
|
|
8
8
|
# Tina4::Container.register(:cache, RedisCacheInstance) # concrete instance (always same)
|
|
9
9
|
# Tina4::Container.get(:db) # => Database instance
|
|
10
10
|
#
|
data/lib/tina4/database.rb
CHANGED
|
@@ -91,20 +91,20 @@ module Tina4
|
|
|
91
91
|
|
|
92
92
|
# Construct a Database from environment variables.
|
|
93
93
|
# Returns nil if the named env var is not set.
|
|
94
|
-
def self.from_env(env_key: "
|
|
94
|
+
def self.from_env(env_key: "TINA4_DATABASE_URL", pool: 0)
|
|
95
95
|
url = ENV[env_key]
|
|
96
96
|
return nil if url.nil? || url.strip.empty?
|
|
97
97
|
|
|
98
98
|
new(url,
|
|
99
|
-
username: ENV["
|
|
100
|
-
password: ENV["
|
|
99
|
+
username: ENV["TINA4_DATABASE_USERNAME"],
|
|
100
|
+
password: ENV["TINA4_DATABASE_PASSWORD"],
|
|
101
101
|
pool: pool)
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: 0)
|
|
105
|
-
@connection_string = connection_string || ENV["
|
|
106
|
-
@username = username || ENV["
|
|
107
|
-
@password = password || ENV["
|
|
105
|
+
@connection_string = connection_string || ENV["TINA4_DATABASE_URL"]
|
|
106
|
+
@username = username || ENV["TINA4_DATABASE_USERNAME"]
|
|
107
|
+
@password = password || ENV["TINA4_DATABASE_PASSWORD"]
|
|
108
108
|
@driver_name = driver_name || detect_driver(@connection_string)
|
|
109
109
|
@pool_size = pool # 0 = single connection, N>0 = N pooled connections
|
|
110
110
|
@connected = false
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -694,7 +694,7 @@ module Tina4
|
|
|
694
694
|
platform: RUBY_PLATFORM,
|
|
695
695
|
debug: ENV["TINA4_DEBUG"] || "false",
|
|
696
696
|
log_level: ENV["TINA4_LOG_LEVEL"] || "ERROR",
|
|
697
|
-
database: ENV["
|
|
697
|
+
database: ENV["TINA4_DATABASE_URL"] || "not configured",
|
|
698
698
|
db_tables: db_table_count,
|
|
699
699
|
uptime: (Time.now - (defined?(@boot_time) && @boot_time ? @boot_time : (@boot_time = Time.now))).round(1),
|
|
700
700
|
route_count: Tina4::Router.routes.size,
|
|
@@ -41,10 +41,44 @@ module Tina4
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def last_insert_id
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
# Issue #38: ``SELECT lastval()`` raises on tables with no sequence
|
|
45
|
+
# (UUID, ULID, hash PKs etc.). The exception itself isn't fatal,
|
|
46
|
+
# but the pg gem marks the whole transaction as aborted, so every
|
|
47
|
+
# subsequent statement on this connection fails with
|
|
48
|
+
# ``PG::InFailedSqlTransaction`` — far away from the real cause.
|
|
49
|
+
#
|
|
50
|
+
# Fix: wrap the probe in a SAVEPOINT. If ``lastval()`` raises, we
|
|
51
|
+
# ROLLBACK TO SAVEPOINT and the outer transaction stays usable;
|
|
52
|
+
# ``last_insert_id`` just returns ``nil`` (same as before for
|
|
53
|
+
# tables without a sequence). On success we RELEASE SAVEPOINT.
|
|
54
|
+
begin
|
|
55
|
+
@connection.exec("SAVEPOINT _t4_lastval_probe")
|
|
56
|
+
rescue PG::Error
|
|
57
|
+
# No active transaction (autocommit/idle) — fall back to a plain
|
|
58
|
+
# probe; psycopg2-style transaction abort can't happen here.
|
|
59
|
+
begin
|
|
60
|
+
result = @connection.exec("SELECT lastval()")
|
|
61
|
+
return result.first["lastval"].to_i
|
|
62
|
+
rescue PG::Error
|
|
63
|
+
return nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
begin
|
|
68
|
+
result = @connection.exec("SELECT lastval()")
|
|
69
|
+
@connection.exec("RELEASE SAVEPOINT _t4_lastval_probe")
|
|
70
|
+
result.first["lastval"].to_i
|
|
71
|
+
rescue PG::Error
|
|
72
|
+
begin
|
|
73
|
+
@connection.exec("ROLLBACK TO SAVEPOINT _t4_lastval_probe")
|
|
74
|
+
@connection.exec("RELEASE SAVEPOINT _t4_lastval_probe")
|
|
75
|
+
rescue PG::Error
|
|
76
|
+
# If even the rollback fails, there's nothing we can do — the
|
|
77
|
+
# connection is in a state we can't recover. Surface nil so
|
|
78
|
+
# callers don't get a half-set last_id.
|
|
79
|
+
end
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
48
82
|
end
|
|
49
83
|
|
|
50
84
|
def placeholder
|
data/lib/tina4/env.rb
CHANGED
|
@@ -2,14 +2,85 @@
|
|
|
2
2
|
require "digest"
|
|
3
3
|
|
|
4
4
|
module Tina4
|
|
5
|
+
# Legacy env var names that v3.12 has retired. If any of these are set in
|
|
6
|
+
# the environment we refuse to boot — silently ignoring them would cause
|
|
7
|
+
# auth/db/mail to fall back to defaults with no warning. Each maps to its
|
|
8
|
+
# new TINA4_-prefixed canonical name.
|
|
9
|
+
LEGACY_ENV_VARS = {
|
|
10
|
+
"DATABASE_URL" => "TINA4_DATABASE_URL",
|
|
11
|
+
"DATABASE_USERNAME" => "TINA4_DATABASE_USERNAME",
|
|
12
|
+
"DATABASE_PASSWORD" => "TINA4_DATABASE_PASSWORD",
|
|
13
|
+
"DB_URL" => "TINA4_DATABASE_URL",
|
|
14
|
+
"SECRET" => "TINA4_SECRET",
|
|
15
|
+
"API_KEY" => "TINA4_API_KEY",
|
|
16
|
+
"JWT_ALGORITHM" => "TINA4_JWT_ALGORITHM",
|
|
17
|
+
"SMTP_HOST" => "TINA4_MAIL_HOST",
|
|
18
|
+
"SMTP_PORT" => "TINA4_MAIL_PORT",
|
|
19
|
+
"SMTP_USERNAME" => "TINA4_MAIL_USERNAME",
|
|
20
|
+
"SMTP_PASSWORD" => "TINA4_MAIL_PASSWORD",
|
|
21
|
+
"SMTP_FROM" => "TINA4_MAIL_FROM",
|
|
22
|
+
"SMTP_FROM_NAME" => "TINA4_MAIL_FROM_NAME",
|
|
23
|
+
"IMAP_HOST" => "TINA4_MAIL_IMAP_HOST",
|
|
24
|
+
"IMAP_PORT" => "TINA4_MAIL_IMAP_PORT",
|
|
25
|
+
"IMAP_USER" => "TINA4_MAIL_IMAP_USERNAME",
|
|
26
|
+
"IMAP_PASS" => "TINA4_MAIL_IMAP_PASSWORD",
|
|
27
|
+
"HOST_NAME" => "TINA4_HOST_NAME",
|
|
28
|
+
"SWAGGER_TITLE" => "TINA4_SWAGGER_TITLE",
|
|
29
|
+
"SWAGGER_DESCRIPTION" => "TINA4_SWAGGER_DESCRIPTION",
|
|
30
|
+
"SWAGGER_VERSION" => "TINA4_SWAGGER_VERSION",
|
|
31
|
+
"ORM_PLURAL_TABLE_NAMES" => "TINA4_ORM_PLURAL_TABLE_NAMES"
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
# Raised by check_legacy_env_vars! when the caller opts out of process exit.
|
|
35
|
+
class LegacyEnvError < StandardError; end
|
|
36
|
+
|
|
37
|
+
# Refuse to boot if pre-3.12 un-prefixed env vars are still set.
|
|
38
|
+
#
|
|
39
|
+
# Tina4 v3.12 hard-renamed every framework-specific env var to use the
|
|
40
|
+
# TINA4_ prefix. Booting silently with a legacy DATABASE_URL or SECRET
|
|
41
|
+
# would let auth, DB, or mail fall back to insecure defaults while the
|
|
42
|
+
# user thought their config was being read. Better to die loudly with a
|
|
43
|
+
# list of names to fix.
|
|
44
|
+
#
|
|
45
|
+
# Bypass with TINA4_ALLOW_LEGACY_ENV=true in CI / migration scripts that
|
|
46
|
+
# genuinely need both names set during a transition window.
|
|
47
|
+
def self.check_legacy_env_vars!(io: $stderr, exit_on_error: true)
|
|
48
|
+
bypass = ENV["TINA4_ALLOW_LEGACY_ENV"].to_s.downcase
|
|
49
|
+
return if %w[true 1 yes].include?(bypass)
|
|
50
|
+
|
|
51
|
+
found = LEGACY_ENV_VARS.keys.select { |name| ENV.key?(name) }.sort
|
|
52
|
+
return if found.empty?
|
|
53
|
+
|
|
54
|
+
sep = "─" * 72
|
|
55
|
+
lines = ["", sep,
|
|
56
|
+
"Tina4 v3.12 requires TINA4_ prefix on all framework env vars.",
|
|
57
|
+
"Your environment still has these legacy names:",
|
|
58
|
+
""]
|
|
59
|
+
found.each do |old|
|
|
60
|
+
new_name = LEGACY_ENV_VARS[old]
|
|
61
|
+
lines << format(" %-28s → %s", old, new_name)
|
|
62
|
+
end
|
|
63
|
+
lines.concat([
|
|
64
|
+
"",
|
|
65
|
+
"Run `tina4 env-migrate` to rewrite your .env automatically,",
|
|
66
|
+
"or rename manually. See https://tina4.com/release/3.12.0",
|
|
67
|
+
"Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
|
|
68
|
+
sep, ""
|
|
69
|
+
])
|
|
70
|
+
io.puts lines.join("\n")
|
|
71
|
+
raise LegacyEnvError, "Legacy env vars present: #{found.join(', ')}" unless exit_on_error
|
|
72
|
+
|
|
73
|
+
exit(2)
|
|
74
|
+
end
|
|
75
|
+
|
|
5
76
|
module Env
|
|
6
77
|
DEFAULT_ENV = {
|
|
7
78
|
"PROJECT_NAME" => "Tina4 Ruby Project",
|
|
8
|
-
"
|
|
79
|
+
"TINA4_SWAGGER_VERSION" => "1.0.0",
|
|
9
80
|
"TINA4_LOCALE" => "en",
|
|
10
81
|
"TINA4_DEBUG" => "true",
|
|
11
82
|
"TINA4_LOG_LEVEL" => "[TINA4_LOG_ALL]",
|
|
12
|
-
"
|
|
83
|
+
"TINA4_SECRET" => "tina4-secret-change-me"
|
|
13
84
|
}.freeze
|
|
14
85
|
|
|
15
86
|
# Check if a value is truthy for env boolean checks.
|
|
@@ -72,7 +143,7 @@ module Tina4
|
|
|
72
143
|
def create_default_env(path)
|
|
73
144
|
api_key = Digest::MD5.hexdigest(Time.now.to_s)
|
|
74
145
|
content = DEFAULT_ENV.map { |k, v| "#{k}=\"#{v}\"" }.join("\n")
|
|
75
|
-
content += "\
|
|
146
|
+
content += "\nTINA4_API_KEY=\"#{api_key}\"\n"
|
|
76
147
|
File.write(path, content)
|
|
77
148
|
end
|
|
78
149
|
|
data/lib/tina4/field_types.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Tina4
|
|
|
21
21
|
else
|
|
22
22
|
base = self.name.split("::").last.downcase
|
|
23
23
|
# Pluralize by default (add "s") unless ORM_PLURAL_TABLE_NAMES is explicitly disabled
|
|
24
|
-
unless ENV.fetch("
|
|
24
|
+
unless ENV.fetch("TINA4_ORM_PLURAL_TABLE_NAMES", "").match?(/\A(false|0|no)\z/i)
|
|
25
25
|
base += "s" unless base.end_with?("s")
|
|
26
26
|
end
|
|
27
27
|
@table_name || base
|
data/lib/tina4/mcp.rb
CHANGED
|
@@ -132,7 +132,7 @@ module Tina4
|
|
|
132
132
|
|
|
133
133
|
# Check if the server is running on localhost.
|
|
134
134
|
def self.is_localhost?
|
|
135
|
-
host = ENV.fetch("
|
|
135
|
+
host = ENV.fetch("TINA4_HOST_NAME", "localhost:7145").split(":").first
|
|
136
136
|
["localhost", "127.0.0.1", "0.0.0.0", "::1", ""].include?(host)
|
|
137
137
|
end
|
|
138
138
|
|
data/lib/tina4/messenger.rb
CHANGED
|
@@ -18,7 +18,7 @@ module Tina4
|
|
|
18
18
|
# Tina4 Messenger — Email sending (SMTP) and reading (IMAP).
|
|
19
19
|
#
|
|
20
20
|
# Unified .env-driven configuration with constructor override.
|
|
21
|
-
# Priority: constructor params > .env (TINA4_MAIL_*
|
|
21
|
+
# Priority: constructor params > .env (TINA4_MAIL_*) > sensible defaults
|
|
22
22
|
#
|
|
23
23
|
# # .env
|
|
24
24
|
# TINA4_MAIL_HOST=smtp.gmail.com
|
|
@@ -39,19 +39,19 @@ module Tina4
|
|
|
39
39
|
:imap_host, :imap_port, :use_tls, :encryption
|
|
40
40
|
|
|
41
41
|
# Initialize with SMTP config.
|
|
42
|
-
# Priority: constructor params > ENV (TINA4_MAIL_*
|
|
42
|
+
# Priority: constructor params > ENV (TINA4_MAIL_*) > sensible defaults
|
|
43
43
|
def initialize(host: nil, port: nil, username: nil, password: nil,
|
|
44
44
|
from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
|
|
45
45
|
imap_host: nil, imap_port: nil)
|
|
46
|
-
@host = host || ENV["TINA4_MAIL_HOST"] ||
|
|
47
|
-
@port = (port || ENV["TINA4_MAIL_PORT"] ||
|
|
48
|
-
@username = username || ENV["TINA4_MAIL_USERNAME"]
|
|
49
|
-
@password = password || ENV["TINA4_MAIL_PASSWORD"]
|
|
46
|
+
@host = host || ENV["TINA4_MAIL_HOST"] || "localhost"
|
|
47
|
+
@port = (port || ENV["TINA4_MAIL_PORT"] || 587).to_i
|
|
48
|
+
@username = username || ENV["TINA4_MAIL_USERNAME"]
|
|
49
|
+
@password = password || ENV["TINA4_MAIL_PASSWORD"]
|
|
50
50
|
|
|
51
|
-
resolved_from = from_address || ENV["TINA4_MAIL_FROM"]
|
|
51
|
+
resolved_from = from_address || ENV["TINA4_MAIL_FROM"]
|
|
52
52
|
@from_address = resolved_from || @username || "noreply@localhost"
|
|
53
53
|
|
|
54
|
-
@from_name = from_name || ENV["TINA4_MAIL_FROM_NAME"] ||
|
|
54
|
+
@from_name = from_name || ENV["TINA4_MAIL_FROM_NAME"] || ""
|
|
55
55
|
|
|
56
56
|
# Encryption: constructor > .env > backward-compat use_tls > default "tls"
|
|
57
57
|
env_encryption = encryption || ENV["TINA4_MAIL_ENCRYPTION"]
|
|
@@ -64,8 +64,8 @@ module Tina4
|
|
|
64
64
|
end
|
|
65
65
|
@use_tls = %w[tls starttls].include?(@encryption)
|
|
66
66
|
|
|
67
|
-
@imap_host = imap_host || ENV["TINA4_MAIL_IMAP_HOST"] ||
|
|
68
|
-
@imap_port = (imap_port || ENV["TINA4_MAIL_IMAP_PORT"] ||
|
|
67
|
+
@imap_host = imap_host || ENV["TINA4_MAIL_IMAP_HOST"] || @host
|
|
68
|
+
@imap_port = (imap_port || ENV["TINA4_MAIL_IMAP_PORT"] || 993).to_i
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
# Send email using Ruby's Net::SMTP
|
|
@@ -542,8 +542,7 @@ module Tina4
|
|
|
542
542
|
def self.create_messenger(**options)
|
|
543
543
|
dev_mode = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
|
|
544
544
|
|
|
545
|
-
smtp_configured =
|
|
546
|
-
(ENV["SMTP_HOST"] && !ENV["SMTP_HOST"].empty?)
|
|
545
|
+
smtp_configured = ENV["TINA4_MAIL_HOST"] && !ENV["TINA4_MAIL_HOST"].empty?
|
|
547
546
|
|
|
548
547
|
if dev_mode && !smtp_configured
|
|
549
548
|
mailbox_dir = options.delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"]
|
|
@@ -560,8 +559,8 @@ module Tina4
|
|
|
560
559
|
|
|
561
560
|
def initialize(mailbox, **options)
|
|
562
561
|
@mailbox = mailbox
|
|
563
|
-
@from_address = options[:from_address] || ENV["TINA4_MAIL_FROM"] ||
|
|
564
|
-
@from_name = options[:from_name] || ENV["TINA4_MAIL_FROM_NAME"] ||
|
|
562
|
+
@from_address = options[:from_address] || ENV["TINA4_MAIL_FROM"] || "dev@localhost"
|
|
563
|
+
@from_name = options[:from_name] || ENV["TINA4_MAIL_FROM_NAME"] || "Dev Mailer"
|
|
565
564
|
end
|
|
566
565
|
|
|
567
566
|
def send(to:, subject:, body:, html: false, cc: [], bcc: [],
|
data/lib/tina4/orm.rb
CHANGED
|
@@ -372,6 +372,34 @@ module Tina4
|
|
|
372
372
|
select_one(sql, [id])
|
|
373
373
|
end
|
|
374
374
|
|
|
375
|
+
# Clear the relationship cache on all loaded instances (class-level helper).
|
|
376
|
+
# Useful after bulk operations when you want to force relationship re-loads.
|
|
377
|
+
def clear_rel_cache # -> nil
|
|
378
|
+
@_rel_cache = {}
|
|
379
|
+
nil
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Return the database connection used by this model.
|
|
383
|
+
def get_db # -> Database
|
|
384
|
+
db
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Map a Ruby property name to its database column name using field_mapping.
|
|
388
|
+
# Returns the column name as a symbol.
|
|
389
|
+
def get_db_column(property) # -> Symbol
|
|
390
|
+
col = field_mapping[property.to_s] || property
|
|
391
|
+
col.to_sym
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
private
|
|
395
|
+
|
|
396
|
+
def auto_discover_db
|
|
397
|
+
url = ENV["TINA4_DATABASE_URL"]
|
|
398
|
+
return nil unless url
|
|
399
|
+
Tina4.database = Tina4::Database.new(url, username: ENV.fetch("TINA4_DATABASE_USERNAME", ""), password: ENV.fetch("TINA4_DATABASE_PASSWORD", ""))
|
|
400
|
+
Tina4.database
|
|
401
|
+
end
|
|
402
|
+
|
|
375
403
|
def find_by_filter(filter)
|
|
376
404
|
where_parts = filter.keys.map { |k| "#{k} = ?" }
|
|
377
405
|
sql = "SELECT * FROM #{table_name} WHERE #{where_parts.join(' AND ')}"
|
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
var _frondModule = (() => {
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/js/frond.ts
|
|
21
|
+
var frond_exports = {};
|
|
22
|
+
__export(frond_exports, {
|
|
23
|
+
frond: () => frond
|
|
24
|
+
});
|
|
25
|
+
var _token = null;
|
|
26
|
+
function request(url, options) {
|
|
27
|
+
let opts;
|
|
28
|
+
if (typeof options === "function") {
|
|
29
|
+
opts = { onSuccess: options };
|
|
30
|
+
} else {
|
|
31
|
+
opts = options || {};
|
|
32
|
+
}
|
|
33
|
+
const method = (opts.method || "GET").toUpperCase();
|
|
34
|
+
const xhr = new XMLHttpRequest();
|
|
35
|
+
xhr.open(method, url, true);
|
|
36
|
+
if (_token !== null) {
|
|
37
|
+
xhr.setRequestHeader("Authorization", "Bearer " + _token);
|
|
38
|
+
}
|
|
39
|
+
if (opts.headers) {
|
|
40
|
+
for (const key in opts.headers) {
|
|
41
|
+
if (Object.prototype.hasOwnProperty.call(opts.headers, key)) {
|
|
42
|
+
xhr.setRequestHeader(key, opts.headers[key]);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
let body = null;
|
|
47
|
+
if (opts.body !== void 0 && opts.body !== null) {
|
|
48
|
+
if (opts.body instanceof FormData) {
|
|
49
|
+
body = opts.body;
|
|
50
|
+
} else if (typeof opts.body === "object") {
|
|
51
|
+
body = JSON.stringify(opts.body);
|
|
52
|
+
xhr.setRequestHeader("Content-Type", "application/json; charset=UTF-8");
|
|
53
|
+
} else if (typeof opts.body === "string") {
|
|
54
|
+
body = opts.body;
|
|
55
|
+
xhr.setRequestHeader("Content-Type", "text/plain; charset=UTF-8");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
xhr.onload = function() {
|
|
59
|
+
const freshToken = xhr.getResponseHeader("FreshToken");
|
|
60
|
+
if (freshToken && freshToken !== "") {
|
|
61
|
+
_token = freshToken;
|
|
62
|
+
}
|
|
63
|
+
let content = xhr.response;
|
|
64
|
+
try {
|
|
65
|
+
content = JSON.parse(content);
|
|
66
|
+
} catch {
|
|
67
|
+
}
|
|
68
|
+
if (xhr.responseURL) {
|
|
69
|
+
const requested = new URL(url, window.location.href).href;
|
|
70
|
+
if (xhr.responseURL !== requested) {
|
|
71
|
+
window.location.href = xhr.responseURL;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (xhr.status >= 200 && xhr.status < 400) {
|
|
76
|
+
if (opts.onSuccess) opts.onSuccess(content, xhr.status, xhr);
|
|
77
|
+
} else {
|
|
78
|
+
if (opts.onError) opts.onError(xhr.status, xhr);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
xhr.onerror = function() {
|
|
82
|
+
if (opts.onError) opts.onError(xhr.status, xhr);
|
|
83
|
+
};
|
|
84
|
+
xhr.send(body);
|
|
85
|
+
}
|
|
86
|
+
function inject(html, target) {
|
|
87
|
+
if (!html) return "";
|
|
88
|
+
const parser = new DOMParser();
|
|
89
|
+
const wrapped = html.includes("<html>") ? html : "<body>" + html + "</body></html>";
|
|
90
|
+
const doc = parser.parseFromString(wrapped, "text/html");
|
|
91
|
+
const body = doc.querySelector("body");
|
|
92
|
+
const scripts = body.querySelectorAll("script");
|
|
93
|
+
scripts.forEach(function(s) {
|
|
94
|
+
s.remove();
|
|
95
|
+
});
|
|
96
|
+
if (target !== null) {
|
|
97
|
+
const el = document.getElementById(target);
|
|
98
|
+
if (!el) return "";
|
|
99
|
+
if (body.children.length > 0) {
|
|
100
|
+
el.replaceChildren.apply(el, Array.from(body.children));
|
|
101
|
+
} else {
|
|
102
|
+
el.innerHTML = body.innerHTML;
|
|
103
|
+
}
|
|
104
|
+
scripts.forEach(function(script) {
|
|
105
|
+
const ns = document.createElement("script");
|
|
106
|
+
ns.type = "text/javascript";
|
|
107
|
+
ns.async = true;
|
|
108
|
+
if (script.src) {
|
|
109
|
+
ns.src = script.src;
|
|
110
|
+
} else {
|
|
111
|
+
ns.textContent = script.textContent;
|
|
112
|
+
}
|
|
113
|
+
el.appendChild(ns);
|
|
114
|
+
});
|
|
115
|
+
return "";
|
|
116
|
+
}
|
|
117
|
+
scripts.forEach(function(script) {
|
|
118
|
+
const ns = document.createElement("script");
|
|
119
|
+
ns.type = "text/javascript";
|
|
120
|
+
ns.async = true;
|
|
121
|
+
ns.textContent = script.textContent;
|
|
122
|
+
document.body.appendChild(ns);
|
|
123
|
+
});
|
|
124
|
+
return body.innerHTML;
|
|
125
|
+
}
|
|
126
|
+
function load(url, target, callback) {
|
|
127
|
+
const targetId = target || "content";
|
|
128
|
+
request(url, {
|
|
129
|
+
method: "GET",
|
|
130
|
+
onSuccess: function(data, _status) {
|
|
131
|
+
if (document.getElementById(targetId)) {
|
|
132
|
+
const html = inject(data, targetId);
|
|
133
|
+
if (callback) callback(html, data);
|
|
134
|
+
} else {
|
|
135
|
+
if (callback) callback(data);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function post(url, data, target, callback) {
|
|
141
|
+
const targetId = target || "content";
|
|
142
|
+
request(url, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
body: data,
|
|
145
|
+
onSuccess: function(responseData) {
|
|
146
|
+
let html = "";
|
|
147
|
+
if (responseData && responseData.message !== void 0) {
|
|
148
|
+
html = inject(responseData.message, targetId);
|
|
149
|
+
} else if (document.getElementById(targetId)) {
|
|
150
|
+
html = inject(responseData, targetId);
|
|
151
|
+
} else {
|
|
152
|
+
if (callback) callback(responseData);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (callback) callback(html, responseData);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
var form = {
|
|
160
|
+
/**
|
|
161
|
+
* Collect all form field values into a FormData object.
|
|
162
|
+
*
|
|
163
|
+
* Handles inputs, selects, textareas, file uploads (including
|
|
164
|
+
* multi-file), checkboxes, and radio buttons. Updates formToken
|
|
165
|
+
* hidden fields automatically.
|
|
166
|
+
*
|
|
167
|
+
* @param formId - DOM id of the form (without '#').
|
|
168
|
+
* @returns Populated FormData instance.
|
|
169
|
+
*/
|
|
170
|
+
collect: function(formId) {
|
|
171
|
+
const fd = new FormData();
|
|
172
|
+
const elements = document.querySelectorAll("#" + formId + " select, #" + formId + " input, #" + formId + " textarea");
|
|
173
|
+
for (let i = 0; i < elements.length; i++) {
|
|
174
|
+
const el = elements[i];
|
|
175
|
+
if (el.name === "formToken" && _token !== null) {
|
|
176
|
+
el.value = _token;
|
|
177
|
+
}
|
|
178
|
+
if (!el.name) continue;
|
|
179
|
+
if (el.type === "file") {
|
|
180
|
+
const files = el.files;
|
|
181
|
+
if (files) {
|
|
182
|
+
for (let f = 0; f < files.length; f++) {
|
|
183
|
+
const file = files[f];
|
|
184
|
+
if (file !== void 0) {
|
|
185
|
+
let name = el.name;
|
|
186
|
+
if (files.length > 1 && !name.includes("[")) {
|
|
187
|
+
name = name + "[]";
|
|
188
|
+
}
|
|
189
|
+
fd.append(name, file, file.name);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} else if (el.type === "checkbox" || el.type === "radio") {
|
|
194
|
+
if (el.checked) {
|
|
195
|
+
fd.append(el.name, el.value);
|
|
196
|
+
} else if (el.type !== "radio") {
|
|
197
|
+
fd.append(el.name, "0");
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
fd.append(el.name, el.value === "" ? "" : el.value);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return fd;
|
|
204
|
+
},
|
|
205
|
+
/**
|
|
206
|
+
* Collect form data and POST it to a URL. Inject response into target.
|
|
207
|
+
*
|
|
208
|
+
* @param formId - DOM id of the form.
|
|
209
|
+
* @param url - URL to POST to.
|
|
210
|
+
* @param target - DOM id to inject response into (default: "message").
|
|
211
|
+
* @param callback - Optional callback.
|
|
212
|
+
*/
|
|
213
|
+
submit: function(formId, url, target, callback) {
|
|
214
|
+
const data = form.collect(formId);
|
|
215
|
+
post(url, data, target || "message", callback);
|
|
216
|
+
},
|
|
217
|
+
/**
|
|
218
|
+
* Load a form via the given action and inject response HTML.
|
|
219
|
+
*
|
|
220
|
+
* Accepts friendly names: "create", "edit" map to GET; "delete" maps
|
|
221
|
+
* to DELETE.
|
|
222
|
+
*
|
|
223
|
+
* @param action - HTTP method or friendly name.
|
|
224
|
+
* @param url - URL to fetch.
|
|
225
|
+
* @param target - DOM id to inject into (default: "form").
|
|
226
|
+
* @param callback - Optional callback.
|
|
227
|
+
*/
|
|
228
|
+
show: function(action, url, target, callback) {
|
|
229
|
+
let method = action.toUpperCase();
|
|
230
|
+
if (action === "create" || action === "edit") method = "GET";
|
|
231
|
+
if (action === "delete") method = "DELETE";
|
|
232
|
+
const targetId = target || "form";
|
|
233
|
+
request(url, {
|
|
234
|
+
method,
|
|
235
|
+
onSuccess: function(data) {
|
|
236
|
+
let html = "";
|
|
237
|
+
if (data && data.message !== void 0) {
|
|
238
|
+
html = inject(data.message, targetId);
|
|
239
|
+
} else if (document.getElementById(targetId)) {
|
|
240
|
+
html = inject(data, targetId);
|
|
241
|
+
} else {
|
|
242
|
+
if (callback) callback(data);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (callback) callback(html);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
function wsConnect(url, options) {
|
|
251
|
+
const opts = {
|
|
252
|
+
reconnect: true,
|
|
253
|
+
reconnectDelay: 1e3,
|
|
254
|
+
maxReconnectDelay: 3e4,
|
|
255
|
+
maxReconnectAttempts: Infinity,
|
|
256
|
+
protocols: [],
|
|
257
|
+
onOpen: function() {
|
|
258
|
+
},
|
|
259
|
+
onClose: function() {
|
|
260
|
+
},
|
|
261
|
+
onError: function() {
|
|
262
|
+
},
|
|
263
|
+
...options || {}
|
|
264
|
+
};
|
|
265
|
+
let socket = null;
|
|
266
|
+
let intentionalClose = false;
|
|
267
|
+
let currentDelay = opts.reconnectDelay;
|
|
268
|
+
let attempts = 0;
|
|
269
|
+
let reconnectTimer = null;
|
|
270
|
+
const listeners = {
|
|
271
|
+
message: [],
|
|
272
|
+
open: [],
|
|
273
|
+
close: [],
|
|
274
|
+
error: []
|
|
275
|
+
};
|
|
276
|
+
const managed = {
|
|
277
|
+
status: "connecting",
|
|
278
|
+
send: function(data) {
|
|
279
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
280
|
+
throw new Error("[frond] WebSocket is not connected");
|
|
281
|
+
}
|
|
282
|
+
socket.send(typeof data === "string" ? data : JSON.stringify(data));
|
|
283
|
+
},
|
|
284
|
+
on: function(event, handler) {
|
|
285
|
+
if (!listeners[event]) listeners[event] = [];
|
|
286
|
+
listeners[event].push(handler);
|
|
287
|
+
return function() {
|
|
288
|
+
const arr = listeners[event];
|
|
289
|
+
const idx = arr.indexOf(handler);
|
|
290
|
+
if (idx >= 0) arr.splice(idx, 1);
|
|
291
|
+
};
|
|
292
|
+
},
|
|
293
|
+
close: function(code, reason) {
|
|
294
|
+
intentionalClose = true;
|
|
295
|
+
if (reconnectTimer) {
|
|
296
|
+
clearTimeout(reconnectTimer);
|
|
297
|
+
reconnectTimer = null;
|
|
298
|
+
}
|
|
299
|
+
if (socket) {
|
|
300
|
+
socket.close(code || 1e3, reason || "");
|
|
301
|
+
}
|
|
302
|
+
managed.status = "closed";
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
function parseMessage(data) {
|
|
306
|
+
if (typeof data !== "string") return data;
|
|
307
|
+
try {
|
|
308
|
+
return JSON.parse(data);
|
|
309
|
+
} catch {
|
|
310
|
+
return data;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function scheduleReconnect() {
|
|
314
|
+
if (!opts.reconnect || attempts >= opts.maxReconnectAttempts) return;
|
|
315
|
+
attempts++;
|
|
316
|
+
managed.status = "reconnecting";
|
|
317
|
+
reconnectTimer = setTimeout(function() {
|
|
318
|
+
reconnectTimer = null;
|
|
319
|
+
connect();
|
|
320
|
+
}, currentDelay);
|
|
321
|
+
currentDelay = Math.min(currentDelay * 2, opts.maxReconnectDelay);
|
|
322
|
+
}
|
|
323
|
+
function connect() {
|
|
324
|
+
managed.status = attempts > 0 ? "reconnecting" : "connecting";
|
|
325
|
+
try {
|
|
326
|
+
socket = new WebSocket(url, opts.protocols);
|
|
327
|
+
} catch {
|
|
328
|
+
managed.status = "closed";
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
socket.onopen = function() {
|
|
332
|
+
managed.status = "open";
|
|
333
|
+
attempts = 0;
|
|
334
|
+
currentDelay = opts.reconnectDelay;
|
|
335
|
+
opts.onOpen();
|
|
336
|
+
for (const fn of listeners.open) fn();
|
|
337
|
+
};
|
|
338
|
+
socket.onmessage = function(event) {
|
|
339
|
+
const parsed = parseMessage(event.data);
|
|
340
|
+
for (const fn of listeners.message) fn(parsed);
|
|
341
|
+
};
|
|
342
|
+
socket.onclose = function(event) {
|
|
343
|
+
managed.status = "closed";
|
|
344
|
+
opts.onClose(event.code, event.reason);
|
|
345
|
+
for (const fn of listeners.close) fn(event.code, event.reason);
|
|
346
|
+
if (!intentionalClose) {
|
|
347
|
+
scheduleReconnect();
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
socket.onerror = function(event) {
|
|
351
|
+
opts.onError(event);
|
|
352
|
+
for (const fn of listeners.error) fn(event);
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
connect();
|
|
356
|
+
return managed;
|
|
357
|
+
}
|
|
358
|
+
function sseConnect(url, options) {
|
|
359
|
+
const opts = {
|
|
360
|
+
reconnect: true,
|
|
361
|
+
reconnectDelay: 1e3,
|
|
362
|
+
maxReconnectDelay: 3e4,
|
|
363
|
+
maxReconnectAttempts: Infinity,
|
|
364
|
+
events: [],
|
|
365
|
+
json: true,
|
|
366
|
+
onOpen: function() {
|
|
367
|
+
},
|
|
368
|
+
onClose: function() {
|
|
369
|
+
},
|
|
370
|
+
onError: function() {
|
|
371
|
+
},
|
|
372
|
+
...options || {}
|
|
373
|
+
};
|
|
374
|
+
let source = null;
|
|
375
|
+
let intentionalClose = false;
|
|
376
|
+
let currentDelay = opts.reconnectDelay;
|
|
377
|
+
let attempts = 0;
|
|
378
|
+
let reconnectTimer = null;
|
|
379
|
+
const listeners = {
|
|
380
|
+
message: [],
|
|
381
|
+
open: [],
|
|
382
|
+
close: [],
|
|
383
|
+
error: []
|
|
384
|
+
};
|
|
385
|
+
const managed = {
|
|
386
|
+
status: "connecting",
|
|
387
|
+
on: function(event, handler) {
|
|
388
|
+
if (!listeners[event]) listeners[event] = [];
|
|
389
|
+
listeners[event].push(handler);
|
|
390
|
+
return function() {
|
|
391
|
+
const arr = listeners[event];
|
|
392
|
+
const idx = arr.indexOf(handler);
|
|
393
|
+
if (idx >= 0) arr.splice(idx, 1);
|
|
394
|
+
};
|
|
395
|
+
},
|
|
396
|
+
close: function() {
|
|
397
|
+
intentionalClose = true;
|
|
398
|
+
if (reconnectTimer) {
|
|
399
|
+
clearTimeout(reconnectTimer);
|
|
400
|
+
reconnectTimer = null;
|
|
401
|
+
}
|
|
402
|
+
if (source) {
|
|
403
|
+
source.close();
|
|
404
|
+
source = null;
|
|
405
|
+
}
|
|
406
|
+
managed.status = "closed";
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
function parseData(raw) {
|
|
410
|
+
if (!opts.json) return raw;
|
|
411
|
+
try {
|
|
412
|
+
return JSON.parse(raw);
|
|
413
|
+
} catch {
|
|
414
|
+
return raw;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function dispatch(data, eventName) {
|
|
418
|
+
for (const fn of listeners.message) fn(data, eventName || void 0);
|
|
419
|
+
}
|
|
420
|
+
function scheduleReconnect() {
|
|
421
|
+
if (!opts.reconnect || attempts >= opts.maxReconnectAttempts) return;
|
|
422
|
+
attempts++;
|
|
423
|
+
managed.status = "reconnecting";
|
|
424
|
+
reconnectTimer = setTimeout(function() {
|
|
425
|
+
reconnectTimer = null;
|
|
426
|
+
connect();
|
|
427
|
+
}, currentDelay);
|
|
428
|
+
currentDelay = Math.min(currentDelay * 2, opts.maxReconnectDelay);
|
|
429
|
+
}
|
|
430
|
+
function connect() {
|
|
431
|
+
managed.status = attempts > 0 ? "reconnecting" : "connecting";
|
|
432
|
+
try {
|
|
433
|
+
source = new EventSource(url);
|
|
434
|
+
} catch {
|
|
435
|
+
managed.status = "closed";
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
source.onopen = function() {
|
|
439
|
+
managed.status = "open";
|
|
440
|
+
attempts = 0;
|
|
441
|
+
currentDelay = opts.reconnectDelay;
|
|
442
|
+
opts.onOpen();
|
|
443
|
+
for (const fn of listeners.open) fn(null);
|
|
444
|
+
};
|
|
445
|
+
source.onmessage = function(event) {
|
|
446
|
+
dispatch(parseData(event.data), null);
|
|
447
|
+
};
|
|
448
|
+
for (const name of opts.events) {
|
|
449
|
+
source.addEventListener(name, function(e) {
|
|
450
|
+
dispatch(parseData(e.data), name);
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
source.onerror = function(event) {
|
|
454
|
+
opts.onError(event);
|
|
455
|
+
for (const fn of listeners.error) fn(event);
|
|
456
|
+
if (source && source.readyState === 2) {
|
|
457
|
+
source = null;
|
|
458
|
+
managed.status = "closed";
|
|
459
|
+
opts.onClose();
|
|
460
|
+
for (const fn of listeners.close) fn(null);
|
|
461
|
+
if (!intentionalClose) {
|
|
462
|
+
scheduleReconnect();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
connect();
|
|
468
|
+
return managed;
|
|
469
|
+
}
|
|
470
|
+
var cookie = {
|
|
471
|
+
/**
|
|
472
|
+
* Set a browser cookie.
|
|
473
|
+
*
|
|
474
|
+
* @param name - Cookie name.
|
|
475
|
+
* @param value - Cookie value.
|
|
476
|
+
* @param days - Optional lifetime in days.
|
|
477
|
+
*/
|
|
478
|
+
set: function(name, value, days) {
|
|
479
|
+
let expires = "";
|
|
480
|
+
if (days) {
|
|
481
|
+
const d = /* @__PURE__ */ new Date();
|
|
482
|
+
d.setTime(d.getTime() + days * 24 * 60 * 60 * 1e3);
|
|
483
|
+
expires = "; expires=" + d.toUTCString();
|
|
484
|
+
}
|
|
485
|
+
document.cookie = name + "=" + (value || "") + expires + "; path=/";
|
|
486
|
+
},
|
|
487
|
+
/**
|
|
488
|
+
* Retrieve a cookie value by name.
|
|
489
|
+
*
|
|
490
|
+
* @param name - Cookie name.
|
|
491
|
+
* @returns Cookie value, or null if not found.
|
|
492
|
+
*/
|
|
493
|
+
get: function(name) {
|
|
494
|
+
const nameEQ = name + "=";
|
|
495
|
+
const parts = document.cookie.split(";");
|
|
496
|
+
for (let i = 0; i < parts.length; i++) {
|
|
497
|
+
let c = parts[i];
|
|
498
|
+
while (c.charAt(0) === " ") c = c.substring(1);
|
|
499
|
+
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length);
|
|
500
|
+
}
|
|
501
|
+
return null;
|
|
502
|
+
},
|
|
503
|
+
/**
|
|
504
|
+
* Delete a cookie by name.
|
|
505
|
+
*
|
|
506
|
+
* @param name - Cookie name.
|
|
507
|
+
*/
|
|
508
|
+
remove: function(name) {
|
|
509
|
+
document.cookie = name + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/";
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
function message(text, type) {
|
|
513
|
+
const el = document.getElementById("message");
|
|
514
|
+
if (!el) return;
|
|
515
|
+
const alertType = type || "info";
|
|
516
|
+
el.innerHTML = '<div class="alert alert-' + alertType + ' alert-dismissible">' + text + '<button type="button" class="btn-close" data-t4-dismiss="alert">×</button></div>';
|
|
517
|
+
}
|
|
518
|
+
function popup(url, title, w, h) {
|
|
519
|
+
const dualLeft = window.screenLeft !== void 0 ? window.screenLeft : window.screenX;
|
|
520
|
+
const dualTop = window.screenTop !== void 0 ? window.screenTop : window.screenY;
|
|
521
|
+
const width = window.innerWidth || document.documentElement.clientWidth || screen.width;
|
|
522
|
+
const height = window.innerHeight || document.documentElement.clientHeight || screen.height;
|
|
523
|
+
const zoom = width / window.screen.availWidth;
|
|
524
|
+
const left = (width - w) / 2 / zoom + dualLeft;
|
|
525
|
+
const top = (height - h) / 2 / zoom + dualTop;
|
|
526
|
+
const win = window.open(
|
|
527
|
+
url,
|
|
528
|
+
title,
|
|
529
|
+
"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=" + w / zoom + ",height=" + h / zoom + ",top=" + top + ",left=" + left
|
|
530
|
+
);
|
|
531
|
+
if (window.focus && win) win.focus();
|
|
532
|
+
return win;
|
|
533
|
+
}
|
|
534
|
+
function report(url) {
|
|
535
|
+
if (url.indexOf("No data available") >= 0) {
|
|
536
|
+
window.alert("No data available for this report.");
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
window.open(
|
|
540
|
+
url,
|
|
541
|
+
"_blank",
|
|
542
|
+
"toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0"
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
function graphql(url, query, variables, callback) {
|
|
546
|
+
request(url, {
|
|
547
|
+
method: "POST",
|
|
548
|
+
body: { query, variables: variables || {} },
|
|
549
|
+
onSuccess: function(response) {
|
|
550
|
+
if (callback) {
|
|
551
|
+
callback(response.data || null, response.errors || void 0);
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
onError: function(status) {
|
|
555
|
+
if (callback) {
|
|
556
|
+
callback(null, [{ message: "GraphQL request failed with status " + status }]);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
var frond = {
|
|
562
|
+
/** Core HTTP request. */
|
|
563
|
+
request,
|
|
564
|
+
/** GET + inject HTML into target element. */
|
|
565
|
+
load,
|
|
566
|
+
/** POST + inject HTML into target element. */
|
|
567
|
+
post,
|
|
568
|
+
/** Parse HTML string, inject into element, execute scripts. */
|
|
569
|
+
inject,
|
|
570
|
+
/** Form helpers: collect, submit, show. */
|
|
571
|
+
form,
|
|
572
|
+
/** WebSocket with auto-reconnect. */
|
|
573
|
+
ws: wsConnect,
|
|
574
|
+
/** Server-Sent Events with auto-reconnect. */
|
|
575
|
+
sse: sseConnect,
|
|
576
|
+
/** Cookie helpers: get, set, remove. */
|
|
577
|
+
cookie,
|
|
578
|
+
/** Display alert message in #message element. */
|
|
579
|
+
message,
|
|
580
|
+
/** Open centred popup window. */
|
|
581
|
+
popup,
|
|
582
|
+
/** Open PDF report in new window. */
|
|
583
|
+
report,
|
|
584
|
+
/** Execute a GraphQL query/mutation. */
|
|
585
|
+
graphql,
|
|
586
|
+
/** Current bearer token (read/write). */
|
|
587
|
+
get token() {
|
|
588
|
+
return _token;
|
|
589
|
+
},
|
|
590
|
+
set token(value) {
|
|
591
|
+
_token = value;
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
if (typeof window !== "undefined") {
|
|
595
|
+
window.frond = frond;
|
|
596
|
+
}
|
|
597
|
+
return __toCommonJS(frond_exports);
|
|
598
|
+
})();
|
|
599
|
+
/* Frond v2.1.3 — tina4.com */
|
|
600
|
+
//# sourceMappingURL=frond.js.map
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
var _frondModule=(()=>{var b=Object.defineProperty;var k=Object.getOwnPropertyDescriptor;var x=Object.getOwnPropertyNames;var C=Object.prototype.hasOwnProperty;var O=(o,s)=>{for(var e in s)b(o,e,{get:s[e],enumerable:!0})},M=(o,s,e,t)=>{if(s&&typeof s=="object"||typeof s=="function")for(let n of x(s))!C.call(o,n)&&n!==e&&b(o,n,{get:()=>s[n],enumerable:!(t=k(s,n))||t.enumerable});return o};var q=o=>M(b({},"__esModule",{value:!0}),o);var j={};O(j,{frond:()=>R});var g=null;function w(o,s){let e;typeof s=="function"?e={onSuccess:s}:e=s||{};let t=(e.method||"GET").toUpperCase(),n=new XMLHttpRequest;if(n.open(t,o,!0),g!==null&&n.setRequestHeader("Authorization","Bearer "+g),e.headers)for(let r in e.headers)Object.prototype.hasOwnProperty.call(e.headers,r)&&n.setRequestHeader(r,e.headers[r]);let i=null;e.body!==void 0&&e.body!==null&&(e.body instanceof FormData?i=e.body:typeof e.body=="object"?(i=JSON.stringify(e.body),n.setRequestHeader("Content-Type","application/json; charset=UTF-8")):typeof e.body=="string"&&(i=e.body,n.setRequestHeader("Content-Type","text/plain; charset=UTF-8"))),n.onload=function(){let r=n.getResponseHeader("FreshToken");r&&r!==""&&(g=r);let u=n.response;try{u=JSON.parse(u)}catch{}if(n.responseURL){let c=new URL(o,window.location.href).href;if(n.responseURL!==c){window.location.href=n.responseURL;return}}n.status>=200&&n.status<400?e.onSuccess&&e.onSuccess(u,n.status,n):e.onError&&e.onError(n.status,n)},n.onerror=function(){e.onError&&e.onError(n.status,n)},n.send(i)}function h(o,s){if(!o)return"";let e=new DOMParser,t=o.includes("<html>")?o:"<body>"+o+"</body></html>",i=e.parseFromString(t,"text/html").querySelector("body"),r=i.querySelectorAll("script");if(r.forEach(function(u){u.remove()}),s!==null){let u=document.getElementById(s);return u&&(i.children.length>0?u.replaceChildren.apply(u,Array.from(i.children)):u.innerHTML=i.innerHTML,r.forEach(function(c){let d=document.createElement("script");d.type="text/javascript",d.async=!0,c.src?d.src=c.src:d.textContent=c.textContent,u.appendChild(d)})),""}return r.forEach(function(u){let c=document.createElement("script");c.type="text/javascript",c.async=!0,c.textContent=u.textContent,document.body.appendChild(c)}),i.innerHTML}function H(o,s,e){let t=s||"content";w(o,{method:"GET",onSuccess:function(n,i){if(document.getElementById(t)){let r=h(n,t);e&&e(r,n)}else e&&e(n)}})}function S(o,s,e,t){let n=e||"content";w(o,{method:"POST",body:s,onSuccess:function(i){let r="";if(i&&i.message!==void 0)r=h(i.message,n);else if(document.getElementById(n))r=h(i,n);else{t&&t(i);return}t&&t(r,i)}})}var T={collect:function(o){let s=new FormData,e=document.querySelectorAll("#"+o+" select, #"+o+" input, #"+o+" textarea");for(let t=0;t<e.length;t++){let n=e[t];if(n.name==="formToken"&&g!==null&&(n.value=g),!!n.name)if(n.type==="file"){let i=n.files;if(i)for(let r=0;r<i.length;r++){let u=i[r];if(u!==void 0){let c=n.name;i.length>1&&!c.includes("[")&&(c=c+"[]"),s.append(c,u,u.name)}}}else n.type==="checkbox"||n.type==="radio"?n.checked?s.append(n.name,n.value):n.type!=="radio"&&s.append(n.name,"0"):s.append(n.name,n.value===""?"":n.value)}return s},submit:function(o,s,e,t){let n=T.collect(o);S(s,n,e||"message",t)},show:function(o,s,e,t){let n=o.toUpperCase();(o==="create"||o==="edit")&&(n="GET"),o==="delete"&&(n="DELETE");let i=e||"form";w(s,{method:n,onSuccess:function(r){let u="";if(r&&r.message!==void 0)u=h(r.message,i);else if(document.getElementById(i))u=h(r,i);else{t&&t(r);return}t&&t(u)}})}};function L(o,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,protocols:[],onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},t=null,n=!1,i=e.reconnectDelay,r=0,u=null,c={message:[],open:[],close:[],error:[]},d={status:"connecting",send:function(l){if(!t||t.readyState!==WebSocket.OPEN)throw new Error("[frond] WebSocket is not connected");t.send(typeof l=="string"?l:JSON.stringify(l))},on:function(l,a){return c[l]||(c[l]=[]),c[l].push(a),function(){let f=c[l],m=f.indexOf(a);m>=0&&f.splice(m,1)}},close:function(l,a){n=!0,u&&(clearTimeout(u),u=null),t&&t.close(l||1e3,a||""),d.status="closed"}};function y(l){if(typeof l!="string")return l;try{return JSON.parse(l)}catch{return l}}function p(){!e.reconnect||r>=e.maxReconnectAttempts||(r++,d.status="reconnecting",u=setTimeout(function(){u=null,v()},i),i=Math.min(i*2,e.maxReconnectDelay))}function v(){d.status=r>0?"reconnecting":"connecting";try{t=new WebSocket(o,e.protocols)}catch{d.status="closed";return}t.onopen=function(){d.status="open",r=0,i=e.reconnectDelay,e.onOpen();for(let l of c.open)l()},t.onmessage=function(l){let a=y(l.data);for(let f of c.message)f(a)},t.onclose=function(l){d.status="closed",e.onClose(l.code,l.reason);for(let a of c.close)a(l.code,l.reason);n||p()},t.onerror=function(l){e.onError(l);for(let a of c.error)a(l)}}return v(),d}function D(o,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,events:[],json:!0,onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},t=null,n=!1,i=e.reconnectDelay,r=0,u=null,c={message:[],open:[],close:[],error:[]},d={status:"connecting",on:function(a,f){return c[a]||(c[a]=[]),c[a].push(f),function(){let m=c[a],E=m.indexOf(f);E>=0&&m.splice(E,1)}},close:function(){n=!0,u&&(clearTimeout(u),u=null),t&&(t.close(),t=null),d.status="closed"}};function y(a){if(!e.json)return a;try{return JSON.parse(a)}catch{return a}}function p(a,f){for(let m of c.message)m(a,f||void 0)}function v(){!e.reconnect||r>=e.maxReconnectAttempts||(r++,d.status="reconnecting",u=setTimeout(function(){u=null,l()},i),i=Math.min(i*2,e.maxReconnectDelay))}function l(){d.status=r>0?"reconnecting":"connecting";try{t=new EventSource(o)}catch{d.status="closed";return}t.onopen=function(){d.status="open",r=0,i=e.reconnectDelay,e.onOpen();for(let a of c.open)a(null)},t.onmessage=function(a){p(y(a.data),null)};for(let a of e.events)t.addEventListener(a,function(f){p(y(f.data),a)});t.onerror=function(a){e.onError(a);for(let f of c.error)f(a);if(t&&t.readyState===2){t=null,d.status="closed",e.onClose();for(let f of c.close)f(null);n||v()}}}return l(),d}var W={set:function(o,s,e){let t="";if(e){let n=new Date;n.setTime(n.getTime()+e*24*60*60*1e3),t="; expires="+n.toUTCString()}document.cookie=o+"="+(s||"")+t+"; path=/"},get:function(o){let s=o+"=",e=document.cookie.split(";");for(let t=0;t<e.length;t++){let n=e[t];for(;n.charAt(0)===" ";)n=n.substring(1);if(n.indexOf(s)===0)return n.substring(s.length)}return null},remove:function(o){document.cookie=o+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/"}};function A(o,s){let e=document.getElementById("message");if(!e)return;let t=s||"info";e.innerHTML='<div class="alert alert-'+t+' alert-dismissible">'+o+'<button type="button" class="btn-close" data-t4-dismiss="alert">×</button></div>'}function I(o,s,e,t){let n=window.screenLeft!==void 0?window.screenLeft:window.screenX,i=window.screenTop!==void 0?window.screenTop:window.screenY,r=window.innerWidth||document.documentElement.clientWidth||screen.width,u=window.innerHeight||document.documentElement.clientHeight||screen.height,c=r/window.screen.availWidth,d=(r-e)/2/c+n,y=(u-t)/2/c+i,p=window.open(o,s,"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width="+e/c+",height="+t/c+",top="+y+",left="+d);return window.focus&&p&&p.focus(),p}function N(o){if(o.indexOf("No data available")>=0){window.alert("No data available for this report.");return}window.open(o,"_blank","toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0")}function U(o,s,e,t){w(o,{method:"POST",body:{query:s,variables:e||{}},onSuccess:function(n){t&&t(n.data||null,n.errors||void 0)},onError:function(n){t&&t(null,[{message:"GraphQL request failed with status "+n}])}})}var R={request:w,load:H,post:S,inject:h,form:T,ws:L,sse:D,cookie:W,message:A,popup:I,report:N,graphql:U,get token(){return g},set token(o){g=o}};typeof window<"u"&&(window.frond=R);return q(j);})();
|
|
2
|
-
/* Frond v2 — tina4.com */
|
|
2
|
+
/* Frond v2.1.3 — tina4.com */
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -200,7 +200,7 @@ module Tina4
|
|
|
200
200
|
end
|
|
201
201
|
|
|
202
202
|
# API_KEY bypass — matches tina4_python behavior
|
|
203
|
-
api_key = ENV["TINA4_API_KEY"]
|
|
203
|
+
api_key = ENV["TINA4_API_KEY"]
|
|
204
204
|
if api_key && !api_key.empty? && token == api_key
|
|
205
205
|
env["tina4.auth_payload"] = { "api_key" => true }
|
|
206
206
|
elsif token
|
|
@@ -330,18 +330,28 @@ module Tina4
|
|
|
330
330
|
end
|
|
331
331
|
|
|
332
332
|
def handle_404(path)
|
|
333
|
-
# Try serving a template file (e.g. /hello -> src/templates/hello.twig
|
|
333
|
+
# Try serving a template file (e.g. /hello -> src/templates/pages/hello.twig)
|
|
334
334
|
template_response = try_serve_template(path)
|
|
335
335
|
return template_response if template_response
|
|
336
336
|
|
|
337
|
-
# Show landing page for GET "/"
|
|
338
|
-
|
|
337
|
+
# Show landing page for GET "/" — but ONLY in dev mode.
|
|
338
|
+
# Production never shows it, so the framework version, dev-admin link,
|
|
339
|
+
# and gallery never leak to real users. Symmetric with /__dev/* gating.
|
|
340
|
+
return render_landing_page if path == "/" && dev_mode?
|
|
339
341
|
|
|
340
342
|
Tina4::Log.warning("404 Not Found: #{path}")
|
|
341
343
|
body = Tina4::Template.render_error(404, { "path" => path }) rescue "404 Not Found"
|
|
342
344
|
[404, { "content-type" => "text/html" }, [body]]
|
|
343
345
|
end
|
|
344
346
|
|
|
347
|
+
# Honour TINA4_TEMPLATE_ROUTING=off|false|0|no|disabled as an explicit
|
|
348
|
+
# kill switch. Default: enabled. Drop a file in src/templates/pages/ and
|
|
349
|
+
# it serves at the matching URL — the zero-config Tina4 convention.
|
|
350
|
+
def template_auto_routing_enabled?
|
|
351
|
+
val = ENV.fetch("TINA4_TEMPLATE_ROUTING", "on").to_s.strip.downcase
|
|
352
|
+
!%w[off false 0 no disabled].include?(val)
|
|
353
|
+
end
|
|
354
|
+
|
|
345
355
|
def should_show_landing_page?
|
|
346
356
|
# Check if any index template exists in src/templates/
|
|
347
357
|
templates_dir = File.join(@root_dir, "src", "templates")
|
|
@@ -357,19 +367,39 @@ module Tina4
|
|
|
357
367
|
[200, { "content-type" => "text/html" }, [body]]
|
|
358
368
|
end
|
|
359
369
|
|
|
360
|
-
# Resolve a URL path to a template file
|
|
370
|
+
# Resolve a URL path to a template file in src/templates/pages/.
|
|
371
|
+
#
|
|
372
|
+
# Only files inside ``src/templates/pages/`` auto-route from a URL.
|
|
373
|
+
# Anything in ``src/templates/`` outside ``pages/`` (partials, layouts,
|
|
374
|
+
# base.twig, errors, components) is never served standalone — those
|
|
375
|
+
# remain renderable via {% include %} / {% extends %} / explicit render
|
|
376
|
+
# calls but never auto-serve from a URL.
|
|
377
|
+
#
|
|
378
|
+
# Files starting with ``_`` (e.g. pages/_helper.twig) are always skipped
|
|
379
|
+
# even within pages/ — Hugo/Jekyll convention for private partials.
|
|
380
|
+
#
|
|
361
381
|
# Dev mode: checks filesystem every time for live changes.
|
|
362
382
|
# Production: uses a cached lookup built once at startup.
|
|
383
|
+
#
|
|
384
|
+
# The whole feature can be turned off with ``TINA4_TEMPLATE_ROUTING=off``.
|
|
363
385
|
def resolve_template(path)
|
|
386
|
+
return nil unless template_auto_routing_enabled?
|
|
387
|
+
|
|
364
388
|
clean_path = path.sub(%r{^/}, "")
|
|
365
389
|
clean_path = "index" if clean_path.empty?
|
|
390
|
+
|
|
391
|
+
# Skip underscore-prefixed segments — private files even within pages/.
|
|
392
|
+
return nil if clean_path.split("/").any? { |seg| seg.start_with?("_") }
|
|
393
|
+
|
|
366
394
|
is_dev = %w[true 1 yes].include?(ENV.fetch("TINA4_DEBUG", "false").downcase)
|
|
367
395
|
|
|
368
396
|
if is_dev
|
|
369
|
-
|
|
397
|
+
pages_dir = File.join(@root_dir, "src", "templates", "pages")
|
|
370
398
|
%w[.twig .html].each do |ext|
|
|
371
|
-
|
|
372
|
-
|
|
399
|
+
candidate_inside_pages = clean_path + ext
|
|
400
|
+
if File.file?(File.join(pages_dir, candidate_inside_pages))
|
|
401
|
+
return File.join("pages", candidate_inside_pages)
|
|
402
|
+
end
|
|
373
403
|
end
|
|
374
404
|
return nil
|
|
375
405
|
end
|
|
@@ -379,15 +409,22 @@ module Tina4
|
|
|
379
409
|
@template_cache[clean_path]
|
|
380
410
|
end
|
|
381
411
|
|
|
412
|
+
# Scan src/templates/pages/ once and build url_path -> template_file lookup.
|
|
413
|
+
# Only files under pages/ are eligible — partials, layouts, base.twig,
|
|
414
|
+
# errors etc. remain renderable via explicit render calls but never
|
|
415
|
+
# auto-serve from a URL.
|
|
382
416
|
def build_template_cache
|
|
383
417
|
cache = {}
|
|
384
|
-
|
|
385
|
-
return cache unless File.directory?(
|
|
386
|
-
|
|
387
|
-
Dir.glob(File.join(
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
418
|
+
pages_dir = File.join(@root_dir, "src", "templates", "pages")
|
|
419
|
+
return cache unless File.directory?(pages_dir)
|
|
420
|
+
|
|
421
|
+
Dir.glob(File.join(pages_dir, "**", "*.{twig,html}")).each do |f|
|
|
422
|
+
rel_inside_pages = f.sub(pages_dir + File::SEPARATOR, "").tr("\\", "/")
|
|
423
|
+
# Skip private files even within pages/ (e.g. pages/_helper.twig)
|
|
424
|
+
next if rel_inside_pages.split("/").any? { |seg| seg.start_with?("_") }
|
|
425
|
+
url_path = rel_inside_pages.sub(/\.(twig|html)$/, "")
|
|
426
|
+
rel_from_templates = "pages/#{rel_inside_pages}"
|
|
427
|
+
cache[url_path] ||= rel_from_templates
|
|
391
428
|
end
|
|
392
429
|
cache
|
|
393
430
|
end
|
data/lib/tina4/session.rb
CHANGED
|
@@ -16,7 +16,7 @@ module Tina4
|
|
|
16
16
|
|
|
17
17
|
def initialize(env, options = {})
|
|
18
18
|
@options = DEFAULT_OPTIONS.merge(options)
|
|
19
|
-
@options[:secret] ||= ENV["
|
|
19
|
+
@options[:secret] ||= ENV["TINA4_SECRET"] || "tina4-default-secret"
|
|
20
20
|
@handler = create_handler
|
|
21
21
|
@id = extract_session_id(env) || SecureRandom.hex(32)
|
|
22
22
|
@data = load_session
|
data/lib/tina4/swagger.rb
CHANGED
|
@@ -19,9 +19,9 @@ module Tina4
|
|
|
19
19
|
{
|
|
20
20
|
"openapi" => "3.0.3",
|
|
21
21
|
"info" => {
|
|
22
|
-
"title" => ENV["
|
|
23
|
-
"version" => ENV["
|
|
24
|
-
"description" => "Auto-generated API documentation"
|
|
22
|
+
"title" => ENV["TINA4_SWAGGER_TITLE"] || ENV["PROJECT_NAME"] || "Tina4 API",
|
|
23
|
+
"version" => ENV["TINA4_SWAGGER_VERSION"] || Tina4::VERSION,
|
|
24
|
+
"description" => ENV["TINA4_SWAGGER_DESCRIPTION"] || "Auto-generated API documentation"
|
|
25
25
|
},
|
|
26
26
|
"servers" => [
|
|
27
27
|
{ "url" => "/" }
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4/webserver.rb
CHANGED
data/lib/tina4.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
@@ -342,6 +342,7 @@ files:
|
|
|
342
342
|
- lib/tina4/public/favicon.ico
|
|
343
343
|
- lib/tina4/public/images/logo.svg
|
|
344
344
|
- lib/tina4/public/images/tina4-logo-icon.webp
|
|
345
|
+
- lib/tina4/public/js/frond.js
|
|
345
346
|
- lib/tina4/public/js/frond.min.js
|
|
346
347
|
- lib/tina4/public/js/tina4-dev-admin.js
|
|
347
348
|
- lib/tina4/public/js/tina4-dev-admin.min.js
|