tina4ruby 3.11.36 → 3.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9615f251d5bed0d97024c8e061bdf34d26afb2677e5c6ec989005d45d3edc6c9
4
- data.tar.gz: 7ef7b684a87906e932f3c929cfe57596f67bf81bf63cf35e79b8d343f2ab297a
3
+ metadata.gz: e90eb5ed01201959d94f88cc1fad8e0cb264f746e3ac78abb9067db625419feb
4
+ data.tar.gz: b1b401082f5a9be3485fdf797f5030eaf429d03f28da2ff9ff0feb8453112eaa
5
5
  SHA512:
6
- metadata.gz: ecf726e25d06e8ecbed12874fa4f36edf20286ea10c2c9b6c973a2d9d430dd38933a95240b8d50dd4237b82e076b634527e0c28016aa6e94f6f71a7747d126f8
7
- data.tar.gz: bc0603a6370d2dc27f26ad5cee86c00f233213d57dc904075f70c5befdf28ff81b78d0c25ffa25a42f34ae9cf48d0a33a322e1147d12bc05db1f125db245538f
6
+ metadata.gz: ae0b2c4b1817a8447e9c1ebb70bc040fe862e04d879821f157500d33da66bc50023eb844c865eb1166a7868362059080cd6de7fe22f4c21d000beb5121c27289
7
+ data.tar.gz: 5ba99259a5b3c2073fa7a3dc4d4c65215478132e5254cecce6c9f270ccccb195ad557d5187d1e42e85b1f805311961032523a4f19bb1951a73c74f88053f86e1
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["SECRET"]
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["SECRET"]
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"] || ENV["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"] || ENV["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"] || ENV["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
@@ -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
@@ -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["DB_URL"]) } # singleton — memoised
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
  #
@@ -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: "DATABASE_URL", pool: 0)
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["DATABASE_USERNAME"],
100
- password: ENV["DATABASE_PASSWORD"],
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["DATABASE_URL"]
106
- @username = username || ENV["DATABASE_USERNAME"]
107
- @password = password || ENV["DATABASE_PASSWORD"]
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
@@ -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["DATABASE_URL"] || "not configured",
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
- result = @connection.exec("SELECT lastval()")
45
- result.first["lastval"].to_i
46
- rescue PG::Error
47
- nil
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
- "VERSION" => "1.0.0",
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
- "SECRET" => "tina4-secret-change-me"
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 += "\nAPI_KEY=\"#{api_key}\"\n"
146
+ content += "\nTINA4_API_KEY=\"#{api_key}\"\n"
76
147
  File.write(path, content)
77
148
  end
78
149
 
@@ -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("ORM_PLURAL_TABLE_NAMES", "").match?(/\A(false|0|no)\z/i)
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("HOST_NAME", "localhost:7145").split(":").first
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
 
@@ -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_* with SMTP_* fallback) > sensible defaults
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_* with SMTP_* fallback) > sensible defaults
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"] || ENV["SMTP_HOST"] || "localhost"
47
- @port = (port || ENV["TINA4_MAIL_PORT"] || ENV["SMTP_PORT"] || 587).to_i
48
- @username = username || ENV["TINA4_MAIL_USERNAME"] || ENV["SMTP_USERNAME"]
49
- @password = password || ENV["TINA4_MAIL_PASSWORD"] || ENV["SMTP_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"] || ENV["SMTP_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"] || ENV["SMTP_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"] || ENV["IMAP_HOST"] || @host
68
- @imap_port = (imap_port || ENV["TINA4_MAIL_IMAP_PORT"] || ENV["IMAP_PORT"] || 993).to_i
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 = (ENV["TINA4_MAIL_HOST"] && !ENV["TINA4_MAIL_HOST"].empty?) ||
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"] || ENV["SMTP_FROM"] || "dev@localhost"
564
- @from_name = options[:from_name] || ENV["TINA4_MAIL_FROM_NAME"] || ENV["SMTP_FROM_NAME"] || "Dev Mailer"
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">&times;</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">&times;</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 */
@@ -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"] || ENV["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 or hello.html)
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
- return render_landing_page if path == "/"
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
- templates_dir = File.join(@root_dir, "src", "templates")
397
+ pages_dir = File.join(@root_dir, "src", "templates", "pages")
370
398
  %w[.twig .html].each do |ext|
371
- candidate = clean_path + ext
372
- return candidate if File.file?(File.join(templates_dir, candidate))
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
- templates_dir = File.join(@root_dir, "src", "templates")
385
- return cache unless File.directory?(templates_dir)
386
-
387
- Dir.glob(File.join(templates_dir, "**", "*.{twig,html}")).each do |f|
388
- rel = f.sub(templates_dir + File::SEPARATOR, "").tr("\\", "/")
389
- url_path = rel.sub(/\.(twig|html)$/, "")
390
- cache[url_path] ||= rel
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["SECRET"] || "tina4-default-secret"
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
@@ -17,7 +17,7 @@ module Tina4
17
17
 
18
18
  def initialize(options = {})
19
19
  @ttl = options[:ttl] || 86400
20
- @db = options[:db] || Tina4::Database.new(ENV["DATABASE_URL"])
20
+ @db = options[:db] || Tina4::Database.new(ENV["TINA4_DATABASE_URL"])
21
21
  ensure_table
22
22
  end
23
23
 
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["SWAGGER_TITLE"] || ENV["PROJECT_NAME"] || "Tina4 API",
23
- "version" => ENV["VERSION"] || Tina4::VERSION,
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.11.36"
4
+ VERSION = "3.12.1"
5
5
  end
@@ -54,6 +54,9 @@ module Tina4
54
54
  end
55
55
 
56
56
  def start
57
+ # Refuse to boot with v3.11 / v2 era un-prefixed env vars set.
58
+ Tina4.check_legacy_env_vars!
59
+
57
60
  is_managed = ARGV.include?('--managed')
58
61
  unless is_managed || ENV['TINA4_OVERRIDE_CLIENT'] == 'true'
59
62
  puts
data/lib/tina4.rb CHANGED
@@ -440,7 +440,7 @@ module Tina4
440
440
  end
441
441
 
442
442
  def setup_database
443
- db_url = ENV["DATABASE_URL"] || ENV["DB_URL"]
443
+ db_url = ENV["TINA4_DATABASE_URL"]
444
444
  if db_url && !db_url.empty?
445
445
  begin
446
446
  @database = Tina4::Database.new(db_url)
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.11.36
4
+ version: 3.12.1
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