tina4ruby 3.13.93 → 3.13.94
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/cli.rb +12 -4
- data/lib/tina4/database.rb +143 -33
- data/lib/tina4/dev_mailbox.rb +5 -1
- data/lib/tina4/drivers/sqlite_driver.rb +4 -1
- data/lib/tina4/frond.rb +102 -10
- data/lib/tina4/messenger.rb +69 -46
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +12 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a56dae3f83633c16250748c98a0cc2507c1309db74552c55914555da4b34356d
|
|
4
|
+
data.tar.gz: 661237a63c06e576343655d847088e47becdd1c934e0f34f1f3691494993b09a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ec26f2cbe92f9acd95ec969a62aff4586c7b2f97cdee9cef39173bf3e34532ff0273dc42df079c0aec0ed89f67a46b0ab802c963d220aeecf8bee9e09e7bbbbd
|
|
7
|
+
data.tar.gz: 12fff1300e24b224979b14ce160e8cf1f0f0878faa5c1845861f5f27eb9063e71af13cafb1f026058cab0617c0c3975df7789e91fe9679f78de430c596b25689
|
data/lib/tina4/cli.rb
CHANGED
|
@@ -3223,10 +3223,18 @@ module Tina4
|
|
|
3223
3223
|
|
|
3224
3224
|
EXPOSE 7147
|
|
3225
3225
|
|
|
3226
|
-
# Swagger defaults (override with env vars in docker-compose/k8s if needed)
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3226
|
+
# Swagger defaults (override with env vars in docker-compose/k8s if needed).
|
|
3227
|
+
# The TINA4_ prefix is REQUIRED: the un-prefixed names are the legacy
|
|
3228
|
+
# v2/v3.11 forms, and check_legacy_env_vars! refuses to boot when it
|
|
3229
|
+
# finds one, so a generated image would exit 2 during startup.
|
|
3230
|
+
ENV TINA4_SWAGGER_TITLE="Tina4 API"
|
|
3231
|
+
ENV TINA4_SWAGGER_VERSION="0.1.0"
|
|
3232
|
+
ENV TINA4_SWAGGER_DESCRIPTION="Auto-generated API documentation"
|
|
3233
|
+
|
|
3234
|
+
# Required: WebServer#start exits 1 unless the tina4 CLI launched it
|
|
3235
|
+
# (--managed) or this is set. A container has no CLI supervising it.
|
|
3236
|
+
ENV TINA4_OVERRIDE_CLIENT=true
|
|
3237
|
+
ENV TINA4_DEBUG=false
|
|
3230
3238
|
|
|
3231
3239
|
# Start the server on all interfaces
|
|
3232
3240
|
CMD ["bundle", "exec", "tina4ruby", "start", "-p", "7147", "-h", "0.0.0.0", "--production"]
|
data/lib/tina4/database.rb
CHANGED
|
@@ -5,6 +5,15 @@ require "digest"
|
|
|
5
5
|
require "weakref"
|
|
6
6
|
|
|
7
7
|
module Tina4
|
|
8
|
+
# Raised at the first USE of a database whose connect failed.
|
|
9
|
+
#
|
|
10
|
+
# Connecting is deliberately non-fatal at boot (log loud, then degrade - the same
|
|
11
|
+
# policy the session backends follow), so the failure has to resurface somewhere.
|
|
12
|
+
# It used to resurface as a nil dereference inside the driver
|
|
13
|
+
# ("NoMethodError: private method `exec' called for nil:NilClass"), which named
|
|
14
|
+
# neither the database nor the reason. This carries both.
|
|
15
|
+
class DatabaseConnectionError < StandardError; end
|
|
16
|
+
|
|
8
17
|
# Thread-safe connection pool with round-robin rotation.
|
|
9
18
|
# Connections are created lazily on first use.
|
|
10
19
|
class ConnectionPool
|
|
@@ -191,6 +200,9 @@ module Tina4
|
|
|
191
200
|
pool
|
|
192
201
|
end
|
|
193
202
|
@connected = false
|
|
203
|
+
# Set by #connect when connecting fails; re-raised with context by
|
|
204
|
+
# #current_driver so the first real call says what went wrong.
|
|
205
|
+
@connect_error = nil
|
|
194
206
|
|
|
195
207
|
# Per-instance thread-local key for the transaction adapter pin.
|
|
196
208
|
# Without this pin, every Database method call rotates to a different
|
|
@@ -301,9 +313,21 @@ module Tina4
|
|
|
301
313
|
@driver.autocommit = @autocommit if @driver.respond_to?(:autocommit=)
|
|
302
314
|
|
|
303
315
|
Tina4::Log.info("Database connected: #{@driver_name}")
|
|
316
|
+
@connect_error = nil
|
|
304
317
|
rescue => e
|
|
305
318
|
Tina4::Log.error("Database connection failed: #{e.message}")
|
|
306
319
|
@connected = false
|
|
320
|
+
# REMEMBER the cause. Logging alone is not enough: the driver's own
|
|
321
|
+
# connection stays nil, so the next call used to die inside the driver as
|
|
322
|
+
# NoMethodError: private method `exec' called for nil:NilClass
|
|
323
|
+
# which names neither the database it failed to reach nor why. That cost
|
|
324
|
+
# real debugging time when a missing test database looked like a driver
|
|
325
|
+
# bug. current_driver re-raises this with context instead.
|
|
326
|
+
#
|
|
327
|
+
# Boot still does NOT crash on an unreachable database (deliberate, and
|
|
328
|
+
# the same log-loud-then-degrade policy the session backends use) - the
|
|
329
|
+
# error surfaces at the first actual USE.
|
|
330
|
+
@connect_error = e
|
|
307
331
|
end
|
|
308
332
|
|
|
309
333
|
def close
|
|
@@ -324,6 +348,11 @@ module Tina4
|
|
|
324
348
|
def current_driver
|
|
325
349
|
pinned = Thread.current[@tx_pin_key]
|
|
326
350
|
return pinned if pinned
|
|
351
|
+
|
|
352
|
+
# Fail with the REAL reason, at the point of use. Without this the caller
|
|
353
|
+
# gets a nil dereference from deep inside the driver and has to guess.
|
|
354
|
+
raise Tina4::DatabaseConnectionError, connect_error_message if @connect_error
|
|
355
|
+
|
|
327
356
|
if @pool
|
|
328
357
|
@pool.checkout
|
|
329
358
|
else
|
|
@@ -331,6 +360,22 @@ module Tina4
|
|
|
331
360
|
end
|
|
332
361
|
end
|
|
333
362
|
|
|
363
|
+
# A message that says which database, on which driver, and why - the three
|
|
364
|
+
# things the old nil-dereference told you nothing about.
|
|
365
|
+
def connect_error_message
|
|
366
|
+
target = safe_connection_target
|
|
367
|
+
"Database not connected (#{@driver_name}#{target.empty? ? '' : " -> #{target}"}): " \
|
|
368
|
+
"#{@connect_error.class}: #{@connect_error.message}"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# The connection string with any password stripped, so a raised error can name
|
|
372
|
+
# the target without leaking a credential into a log or an HTTP 500 body.
|
|
373
|
+
def safe_connection_target
|
|
374
|
+
return '' if @connection_string.nil? || @connection_string.empty?
|
|
375
|
+
|
|
376
|
+
@connection_string.sub(%r{://([^:/@]+):[^@]*@}, '://\1:***@')
|
|
377
|
+
end
|
|
378
|
+
|
|
334
379
|
# ── Query Cache ──────────────────────────────────────────────
|
|
335
380
|
|
|
336
381
|
def cache_stats
|
|
@@ -542,57 +587,122 @@ module Tina4
|
|
|
542
587
|
end
|
|
543
588
|
private :write_affected
|
|
544
589
|
|
|
590
|
+
# The table's primary-key columns, introspected once and cached.
|
|
591
|
+
#
|
|
592
|
+
# Returns an ARRAY because a primary key may span several columns. A
|
|
593
|
+
# composite key is still one primary key; it just has more than one column.
|
|
594
|
+
# Returns [] when the table has no primary key or cannot be introspected.
|
|
595
|
+
#
|
|
596
|
+
# Uses the cross-engine columns() contract (v3.13.14, #48), which reports
|
|
597
|
+
# :primary_key per column on every driver.
|
|
598
|
+
def primary_key(table)
|
|
599
|
+
@pk_cache ||= {}
|
|
600
|
+
unless @pk_cache.key?(table)
|
|
601
|
+
@pk_cache[table] = begin
|
|
602
|
+
columns(table).select { |c| c[:primary_key] }.map { |c| c[:name].to_s }
|
|
603
|
+
rescue StandardError
|
|
604
|
+
[]
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
@pk_cache[table]
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# Normalise a filter to [sql, params], accepting a Hash or a String.
|
|
611
|
+
def as_where(filter, params)
|
|
612
|
+
return ["", []] if filter.nil?
|
|
613
|
+
|
|
614
|
+
if filter.is_a?(Hash)
|
|
615
|
+
return ["", []] if filter.empty?
|
|
616
|
+
|
|
617
|
+
drv = current_driver
|
|
618
|
+
[filter.keys.map { |k| "#{k} = #{drv.placeholder}" }.join(" AND "), filter.values]
|
|
619
|
+
else
|
|
620
|
+
[filter.to_s, Array(params)]
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
private :as_where
|
|
624
|
+
|
|
625
|
+
# Update rows. A write with no filter is an error, not a full-table write.
|
|
626
|
+
#
|
|
627
|
+
# With no explicit filter the primary key is taken out of `data` and used as
|
|
628
|
+
# the WHERE clause. With neither a filter nor a primary key in `data` this
|
|
629
|
+
# raises rather than overwriting every row (audit feature 4, P1).
|
|
545
630
|
def update(table, data, filter = {}, params = nil)
|
|
546
|
-
|
|
547
|
-
|
|
631
|
+
where_sql, where_params = as_where(filter, params)
|
|
632
|
+
data = data.dup
|
|
633
|
+
|
|
634
|
+
if where_sql.empty?
|
|
635
|
+
pk_columns = primary_key(table)
|
|
636
|
+
# Resolve each key column to whichever form the caller used (String or
|
|
637
|
+
# Symbol); nil marks one that is absent from the data.
|
|
638
|
+
pk_keys = pk_columns.map do |col|
|
|
639
|
+
if data.key?(col) then col
|
|
640
|
+
elsif data.key?(col.to_sym) then col.to_sym
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
missing = pk_columns.each_with_index.reject { |_, i| pk_keys[i] }.map(&:first)
|
|
644
|
+
|
|
645
|
+
if pk_columns.empty? || !missing.empty?
|
|
646
|
+
raise ArgumentError,
|
|
647
|
+
"update requires a filter or the complete primary key in the data; " \
|
|
648
|
+
"pass a filter explicitly to update multiple rows " \
|
|
649
|
+
"(table=#{table.inspect}, primary key=#{pk_columns.inspect}, " \
|
|
650
|
+
"missing from data=#{missing.inspect}). " \
|
|
651
|
+
"To empty a table use truncate(#{table.inspect})."
|
|
652
|
+
end
|
|
548
653
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
654
|
+
# EVERY key column goes into the WHERE. A composite key built from only
|
|
655
|
+
# its first column would match every row sharing that value - the
|
|
656
|
+
# data-loss bug this method exists to prevent, reintroduced.
|
|
657
|
+
where_params = pk_keys.map { |k| data.delete(k) }
|
|
658
|
+
if data.empty?
|
|
659
|
+
raise ArgumentError,
|
|
660
|
+
"update was given only the primary key #{pk_columns.inspect} and no " \
|
|
661
|
+
"columns to set (table=#{table.inspect})"
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
where_sql = pk_columns.map { |c| "#{c} = #{current_driver.placeholder}" }.join(" AND ")
|
|
557
665
|
end
|
|
558
666
|
|
|
667
|
+
cache_invalidate if @cache_enabled
|
|
668
|
+
drv = current_driver
|
|
559
669
|
set_parts = data.keys.map { |k| "#{k} = #{drv.placeholder}" }
|
|
560
|
-
|
|
561
|
-
sql
|
|
562
|
-
sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
|
|
563
|
-
values = data.values + filter.values
|
|
564
|
-
drv.execute(sql, values)
|
|
670
|
+
sql = "UPDATE #{table} SET #{set_parts.join(', ')} WHERE #{where_sql}"
|
|
671
|
+
drv.execute(sql, data.values + where_params)
|
|
565
672
|
autocommit_standalone_write(drv)
|
|
566
|
-
Tina4::DatabaseResult.new([], affected_rows: write_affected(drv))
|
|
673
|
+
Tina4::DatabaseResult.new([], affected_rows: write_affected(drv), last_id: nil)
|
|
567
674
|
end
|
|
568
675
|
|
|
676
|
+
# Delete rows. A filterless delete raises; use truncate() to empty a table.
|
|
569
677
|
def delete(table, filter = {}, params = nil)
|
|
570
|
-
cache_invalidate if @cache_enabled
|
|
571
|
-
drv = current_driver
|
|
572
|
-
|
|
573
678
|
# List of hashes — delete each row
|
|
574
679
|
if filter.is_a?(Array)
|
|
575
680
|
total = 0
|
|
576
681
|
filter.each { |row| total += delete(table, row).affected_rows }
|
|
577
|
-
return Tina4::DatabaseResult.new([], affected_rows: total)
|
|
682
|
+
return Tina4::DatabaseResult.new([], affected_rows: total, last_id: nil)
|
|
578
683
|
end
|
|
579
684
|
|
|
580
|
-
|
|
581
|
-
if
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
autocommit_standalone_write(drv)
|
|
586
|
-
return Tina4::DatabaseResult.new([], affected_rows: write_affected(drv))
|
|
685
|
+
where_sql, where_params = as_where(filter, params)
|
|
686
|
+
if where_sql.empty?
|
|
687
|
+
raise ArgumentError,
|
|
688
|
+
"delete requires a filter (table=#{table.inspect}). " \
|
|
689
|
+
"To remove every row use truncate(#{table.inspect})."
|
|
587
690
|
end
|
|
588
691
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
692
|
+
cache_invalidate if @cache_enabled
|
|
693
|
+
drv = current_driver
|
|
694
|
+
drv.execute("DELETE FROM #{table} WHERE #{where_sql}", where_params)
|
|
695
|
+
autocommit_standalone_write(drv)
|
|
696
|
+
Tina4::DatabaseResult.new([], affected_rows: write_affected(drv), last_id: nil)
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# Remove every row. The explicit spelling of a whole-table delete.
|
|
700
|
+
def truncate(table)
|
|
701
|
+
cache_invalidate if @cache_enabled
|
|
702
|
+
drv = current_driver
|
|
703
|
+
drv.execute("DELETE FROM #{table} WHERE 1 = 1", [])
|
|
594
704
|
autocommit_standalone_write(drv)
|
|
595
|
-
Tina4::DatabaseResult.new([], affected_rows: write_affected(drv))
|
|
705
|
+
Tina4::DatabaseResult.new([], affected_rows: write_affected(drv), last_id: nil)
|
|
596
706
|
end
|
|
597
707
|
|
|
598
708
|
# Return the last execute() error message, or nil.
|
data/lib/tina4/dev_mailbox.rb
CHANGED
|
@@ -16,7 +16,10 @@ module Tina4
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
# Capture an outgoing email to the local filesystem instead of sending
|
|
19
|
-
|
|
19
|
+
# text: carries the plain-text alternative onto the dev path. Every framework
|
|
20
|
+
# used to drop it, so what you inspected in the mailbox was not what would have
|
|
21
|
+
# been sent -- a mailbox that shows you a different message is worse than none.
|
|
22
|
+
def capture(to:, subject:, body:, html: false, text: nil, cc: [], bcc: [],
|
|
20
23
|
reply_to: nil, from_address: nil, from_name: nil, attachments: [])
|
|
21
24
|
msg_id = SecureRandom.uuid
|
|
22
25
|
timestamp = Time.now
|
|
@@ -30,6 +33,7 @@ module Tina4
|
|
|
30
33
|
reply_to: reply_to,
|
|
31
34
|
subject: subject,
|
|
32
35
|
body: body,
|
|
36
|
+
text: text,
|
|
33
37
|
html: html,
|
|
34
38
|
attachments: store_attachments(msg_id, attachments),
|
|
35
39
|
read: false,
|
|
@@ -169,7 +169,10 @@ module Tina4
|
|
|
169
169
|
type: r[:type],
|
|
170
170
|
nullable: r[:notnull] == 0,
|
|
171
171
|
default: r[:dflt_value],
|
|
172
|
-
|
|
172
|
+
# PRAGMA table_info reports `pk` as the 1-BASED POSITION within the
|
|
173
|
+
# primary key, not a boolean: a composite key gives pk=1, pk=2, ...
|
|
174
|
+
# Testing `== 1` reported only the first column of a composite key.
|
|
175
|
+
primary_key: r[:pk].to_i.positive?
|
|
173
176
|
}
|
|
174
177
|
end
|
|
175
178
|
end
|
data/lib/tina4/frond.rb
CHANGED
|
@@ -163,6 +163,29 @@ module Tina4
|
|
|
163
163
|
elif else elseif endautoescape endblock endcache endfor endif endlive
|
|
164
164
|
endmacro endraw endset endspaceless
|
|
165
165
|
].freeze
|
|
166
|
+
|
|
167
|
+
# Author-written tags the sandbox allow-list governs. Mirrors Python's
|
|
168
|
+
# _GATEABLE_TAGS and PHP's GATEABLE_TAGS. A tag absent from this list is
|
|
169
|
+
# structural, not an author capability, and is never gated -- `block` and
|
|
170
|
+
# `extends` are template inheritance, and `raw` is consumed by the tokenizer.
|
|
171
|
+
# Both spellings of set ({% set x = 1 %} and {% set x %}...{% endset %})
|
|
172
|
+
# dispatch under "set", so one entry covers the pair.
|
|
173
|
+
GATEABLE_TAGS = %w[
|
|
174
|
+
autoescape cache for from if import include live macro set spaceless
|
|
175
|
+
].freeze
|
|
176
|
+
|
|
177
|
+
# Gateable tags that OWN A BODY, mapped to the terminator closing it. A
|
|
178
|
+
# denied tag has to consume its body -- see skip_block.
|
|
179
|
+
BLOCK_TAG_ENDS = {
|
|
180
|
+
"autoescape" => "endautoescape",
|
|
181
|
+
"cache" => "endcache",
|
|
182
|
+
"for" => "endfor",
|
|
183
|
+
"if" => "endif",
|
|
184
|
+
"live" => "endlive",
|
|
185
|
+
"macro" => "endmacro",
|
|
186
|
+
"set" => "endset",
|
|
187
|
+
"spaceless" => "endspaceless"
|
|
188
|
+
}.freeze
|
|
166
189
|
AUTOESCAPE_RE = /\Aautoescape\s+(false|true)/
|
|
167
190
|
STRIPTAGS_RE = /<[^>]+>/
|
|
168
191
|
THOUSANDS_RE = /(\d)(?=(\d{3})+(?!\d))/
|
|
@@ -444,6 +467,28 @@ module Tina4
|
|
|
444
467
|
self
|
|
445
468
|
end
|
|
446
469
|
|
|
470
|
+
# May this filter RUN under the current sandbox?
|
|
471
|
+
#
|
|
472
|
+
# The escaping decision has to ask this rather than read the filter name out
|
|
473
|
+
# of the source: a denied `raw` that still marked its value safe made the
|
|
474
|
+
# allow-list entry governing XSS escaping inert.
|
|
475
|
+
def filter_permitted?(name)
|
|
476
|
+
return true unless @sandbox && @allowed_filters
|
|
477
|
+
|
|
478
|
+
@allowed_filters.include?(name.to_s)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# May this tag run under the current sandbox?
|
|
482
|
+
#
|
|
483
|
+
# One gate for every tag, so the allow-list governs the whole tag vocabulary
|
|
484
|
+
# instead of whichever names somebody remembered to check individually.
|
|
485
|
+
def tag_permitted?(tag)
|
|
486
|
+
return true unless @sandbox && @allowed_tags
|
|
487
|
+
return true unless GATEABLE_TAGS.include?(tag)
|
|
488
|
+
|
|
489
|
+
@allowed_tags.include?(tag)
|
|
490
|
+
end
|
|
491
|
+
|
|
447
492
|
# Utility: HTML escape
|
|
448
493
|
def self.escape_html(str)
|
|
449
494
|
str.to_s.gsub(HTML_ESCAPE_RE, HTML_ESCAPE_MAP)
|
|
@@ -692,6 +737,43 @@ module Tina4
|
|
|
692
737
|
# Token renderer
|
|
693
738
|
# -----------------------------------------------------------------------
|
|
694
739
|
|
|
740
|
+
# Consume a denied tag WITHOUT running it, returning the index past its body.
|
|
741
|
+
#
|
|
742
|
+
# Ruby walks a token stream, so a denied tag cannot simply be dropped the way
|
|
743
|
+
# an AST node can. Advancing by one token would leave the body's tokens to
|
|
744
|
+
# render at the TOP level, leaking exactly the content the sandbox denied.
|
|
745
|
+
# Running the handler and discarding its output is no better: handle_set binds
|
|
746
|
+
# its variable before it returns. Consuming the block gives all three
|
|
747
|
+
# properties at once -- no output, no side effects, correct index.
|
|
748
|
+
def skip_block(tokens, i, tag, content)
|
|
749
|
+
terminator = BLOCK_TAG_ENDS[tag]
|
|
750
|
+
# {% set x = 1 %} is an assignment and owns no body; {% set x %}...{% endset %}
|
|
751
|
+
# captures one. Same exact-"=" test the dispatch uses.
|
|
752
|
+
terminator = nil if tag == "set" && content.include?("=")
|
|
753
|
+
return i + 1 if terminator.nil?
|
|
754
|
+
|
|
755
|
+
depth = 1
|
|
756
|
+
j = i + 1
|
|
757
|
+
while j < tokens.length
|
|
758
|
+
if tokens[j][0] == BLOCK
|
|
759
|
+
inner = strip_tag(tokens[j][1])[0]
|
|
760
|
+
case inner.split[0]
|
|
761
|
+
when tag
|
|
762
|
+
# A nested assignment-form set opens no body, so it must not nest.
|
|
763
|
+
depth += 1 unless tag == "set" && inner.include?("=")
|
|
764
|
+
when terminator
|
|
765
|
+
depth -= 1
|
|
766
|
+
return j + 1 if depth.zero?
|
|
767
|
+
end
|
|
768
|
+
end
|
|
769
|
+
j += 1
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
# Unterminated block: consume to the end. Nothing renders, which is the
|
|
773
|
+
# safe answer -- the alternative leaks the body of a denied tag.
|
|
774
|
+
tokens.length
|
|
775
|
+
end
|
|
776
|
+
|
|
695
777
|
def render_tokens(tokens, context)
|
|
696
778
|
output = []
|
|
697
779
|
i = 0
|
|
@@ -725,6 +807,18 @@ module Tina4
|
|
|
725
807
|
|
|
726
808
|
tag = content.split[0] || ""
|
|
727
809
|
|
|
810
|
+
# ONE sandbox gate for the whole tag vocabulary. Previously only
|
|
811
|
+
# `include` was checked, so every other tag ignored the allow-list --
|
|
812
|
+
# {% autoescape false %} could switch escaping off from inside a
|
|
813
|
+
# sandbox whose tags were restricted to something else entirely.
|
|
814
|
+
unless tag_permitted?(tag)
|
|
815
|
+
i = skip_block(tokens, i, tag, content)
|
|
816
|
+
if strip_a && i < tokens.length && tokens[i][0] == TEXT
|
|
817
|
+
tokens[i] = [TEXT, tokens[i][1].lstrip]
|
|
818
|
+
end
|
|
819
|
+
next
|
|
820
|
+
end
|
|
821
|
+
|
|
728
822
|
case tag
|
|
729
823
|
when "if"
|
|
730
824
|
result, i = handle_if(tokens, i, context)
|
|
@@ -745,12 +839,8 @@ module Tina4
|
|
|
745
839
|
i = handle_set_block(tokens, i, context)
|
|
746
840
|
end
|
|
747
841
|
when "include"
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
else
|
|
751
|
-
output << handle_include(content, context)
|
|
752
|
-
i += 1
|
|
753
|
-
end
|
|
842
|
+
output << handle_include(content, context)
|
|
843
|
+
i += 1
|
|
754
844
|
when "macro"
|
|
755
845
|
i = handle_macro(tokens, i, context)
|
|
756
846
|
when "import"
|
|
@@ -913,14 +1003,16 @@ module Tina4
|
|
|
913
1003
|
is_safe = false
|
|
914
1004
|
filters.each do |fname, args|
|
|
915
1005
|
if fname == "raw" || fname == "safe"
|
|
916
|
-
|
|
1006
|
+
# Decide from what was permitted to RUN, not from what the source
|
|
1007
|
+
# asked for. Marking the value safe here regardless meant a DENIED
|
|
1008
|
+
# raw produced byte-identical output to an allowed one -- the
|
|
1009
|
+
# allow-list entry that governs XSS escaping did nothing at all.
|
|
1010
|
+
is_safe = true if filter_permitted?(fname)
|
|
917
1011
|
next
|
|
918
1012
|
end
|
|
919
1013
|
|
|
920
1014
|
# Sandbox: check filter access
|
|
921
|
-
if @sandbox && @allowed_filters && !@allowed_filters.include?(fname)
|
|
922
|
-
next
|
|
923
|
-
end
|
|
1015
|
+
next if @sandbox && @allowed_filters && !@allowed_filters.include?(fname)
|
|
924
1016
|
|
|
925
1017
|
# Filter + property-access chain: `first.groupSummary` — apply
|
|
926
1018
|
# the filter, then traverse the path on the result. Done BEFORE
|
data/lib/tina4/messenger.rb
CHANGED
|
@@ -71,6 +71,28 @@ module Tina4
|
|
|
71
71
|
# mail.send(to: "user@test.com", subject: "Welcome", body: "<h1>Hello!</h1>", html: true, text: "Hello!")
|
|
72
72
|
#
|
|
73
73
|
class Messenger
|
|
74
|
+
# Factory: returns a Messenger configured for the current environment.
|
|
75
|
+
#
|
|
76
|
+
# Returns ONE concrete type, always. It used to return either a Messenger or a
|
|
77
|
+
# DevMessengerProxy; both happened to expose #send, so Ruby escaped the crash that
|
|
78
|
+
# nodejs#41 describes by luck of naming rather than by design -- but the proxy's
|
|
79
|
+
# #send took no text: keyword, so the documented call raised ArgumentError on a dev
|
|
80
|
+
# messenger. Capture is now a branch inside Messenger#send.
|
|
81
|
+
#
|
|
82
|
+
# The gate is availability, not verbosity: capture when no SMTP host is configured,
|
|
83
|
+
# send when one is EVEN WITH TINA4_DEBUG ON, and TINA4_MAIL_CAPTURE forces capture.
|
|
84
|
+
def self.create_messenger(**options)
|
|
85
|
+
mailbox_dir = options.delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"]
|
|
86
|
+
messenger = Messenger.new(**options)
|
|
87
|
+
messenger.instance_variable_set(:@mailbox_dir, mailbox_dir)
|
|
88
|
+
|
|
89
|
+
# Attach the mailbox eagerly when this messenger will capture, so callers (and
|
|
90
|
+
# the dev dashboard) can inspect it before the first send.
|
|
91
|
+
messenger.dev_mailbox = DevMailbox.new(mailbox_dir: mailbox_dir) if messenger.should_capture?
|
|
92
|
+
|
|
93
|
+
messenger
|
|
94
|
+
end
|
|
95
|
+
|
|
74
96
|
attr_reader :host, :port, :username, :from_address, :from_name,
|
|
75
97
|
:imap_host, :imap_port, :use_tls, :encryption,
|
|
76
98
|
:imap_encryption, :imap_use_tls
|
|
@@ -80,6 +102,14 @@ module Tina4
|
|
|
80
102
|
def initialize(host: nil, port: nil, username: nil, password: nil,
|
|
81
103
|
from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
|
|
82
104
|
imap_host: nil, imap_port: nil, imap_encryption: nil)
|
|
105
|
+
# Whether a host was actually CONFIGURED, which is not the same as @host being
|
|
106
|
+
# set: it falls back to "localhost", so it is never nil and cannot answer
|
|
107
|
+
# "can this messenger send?". The capture gate needs that answer, so record it
|
|
108
|
+
# here while the real inputs are still in scope.
|
|
109
|
+
configured_host = host || ENV["TINA4_MAIL_HOST"]
|
|
110
|
+
@smtp_configured = !configured_host.nil? && !configured_host.to_s.empty?
|
|
111
|
+
@mailbox_dir = nil
|
|
112
|
+
@dev_mailbox = nil
|
|
83
113
|
@host = host || ENV["TINA4_MAIL_HOST"] || "localhost"
|
|
84
114
|
@port = (port || ENV["TINA4_MAIL_PORT"] || 587).to_i
|
|
85
115
|
@username = username || ENV["TINA4_MAIL_USERNAME"]
|
|
@@ -113,8 +143,47 @@ module Tina4
|
|
|
113
143
|
|
|
114
144
|
# Send email using Ruby's Net::SMTP
|
|
115
145
|
# Returns { success: true/false, message: "...", id: "..." }
|
|
146
|
+
# The local mailbox, present only once this messenger has captured something
|
|
147
|
+
# (or eagerly, when create_messenger knows it will).
|
|
148
|
+
attr_accessor :dev_mailbox
|
|
149
|
+
|
|
150
|
+
# Should send capture locally instead of talking to SMTP?
|
|
151
|
+
#
|
|
152
|
+
# Availability decides, not verbosity. With no SMTP host configured sending is
|
|
153
|
+
# impossible, so simulate it into a folder rather than failing -- that is what
|
|
154
|
+
# makes a laptop with no mail server usable, and it is the original Tina4
|
|
155
|
+
# "messages folder" behaviour restored. TINA4_MAIL_CAPTURE forces capture even
|
|
156
|
+
# when a host IS configured.
|
|
157
|
+
#
|
|
158
|
+
# TINA4_DEBUG deliberately does NOT gate this. Debug must still be able to send:
|
|
159
|
+
# tying capture to it means nobody can test a real send from a dev box. The old
|
|
160
|
+
# gate required debug AND no SMTP host, so a dev box with neither set went
|
|
161
|
+
# straight to localhost:587 and failed.
|
|
162
|
+
def should_capture?
|
|
163
|
+
return true if Tina4::Env.is_truthy(ENV["TINA4_MAIL_CAPTURE"])
|
|
164
|
+
|
|
165
|
+
!@smtp_configured
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def dev_mailbox
|
|
169
|
+
@dev_mailbox ||= DevMailbox.new(mailbox_dir: @mailbox_dir)
|
|
170
|
+
end
|
|
171
|
+
|
|
116
172
|
def send(to:, subject:, body:, html: false, text: nil, cc: [], bcc: [],
|
|
117
173
|
reply_to: nil, attachments: [], headers: {})
|
|
174
|
+
# Dev capture is a BRANCH here, not a different object handed back by the
|
|
175
|
+
# factory. create_messenger used to return a DevMessengerProxy whose #send had
|
|
176
|
+
# no text: keyword at all, so the documented call raised ArgumentError and the
|
|
177
|
+
# plain-text alternative was silently dropped from the captured message.
|
|
178
|
+
if should_capture?
|
|
179
|
+
return dev_mailbox.capture(
|
|
180
|
+
to: to, subject: subject, body: body, html: html, text: text,
|
|
181
|
+
cc: cc, bcc: bcc, reply_to: reply_to,
|
|
182
|
+
from_address: @from_address, from_name: @from_name,
|
|
183
|
+
attachments: attachments
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
118
187
|
message_id = "<#{SecureRandom.uuid}@#{@host}>"
|
|
119
188
|
raw = build_message(
|
|
120
189
|
to: to, subject: subject, body: body, html: html, text: text,
|
|
@@ -649,50 +718,4 @@ module Tina4
|
|
|
649
718
|
end
|
|
650
719
|
end
|
|
651
720
|
|
|
652
|
-
# Factory: returns a DevMailbox-intercepting messenger in dev mode,
|
|
653
|
-
# or a real Messenger in production.
|
|
654
|
-
def self.create_messenger(**options)
|
|
655
|
-
dev_mode = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
|
|
656
|
-
|
|
657
|
-
smtp_configured = ENV["TINA4_MAIL_HOST"] && !ENV["TINA4_MAIL_HOST"].empty?
|
|
658
|
-
|
|
659
|
-
if dev_mode && !smtp_configured
|
|
660
|
-
mailbox_dir = options.delete(:mailbox_dir) || ENV["TINA4_MAILBOX_DIR"]
|
|
661
|
-
mailbox = DevMailbox.new(mailbox_dir: mailbox_dir)
|
|
662
|
-
DevMessengerProxy.new(mailbox, **options)
|
|
663
|
-
else
|
|
664
|
-
Messenger.new(**options)
|
|
665
|
-
end
|
|
666
|
-
end
|
|
667
|
-
|
|
668
|
-
# Proxy that wraps DevMailbox with the same interface as Messenger#send
|
|
669
|
-
class DevMessengerProxy
|
|
670
|
-
attr_reader :mailbox
|
|
671
|
-
|
|
672
|
-
def initialize(mailbox, **options)
|
|
673
|
-
@mailbox = mailbox
|
|
674
|
-
@from_address = options[:from_address] || ENV["TINA4_MAIL_FROM"] || "dev@localhost"
|
|
675
|
-
@from_name = options[:from_name] || ENV["TINA4_MAIL_FROM_NAME"] || "Dev Mailer"
|
|
676
|
-
end
|
|
677
|
-
|
|
678
|
-
def send(to:, subject:, body:, html: false, cc: [], bcc: [],
|
|
679
|
-
reply_to: nil, attachments: [], headers: {})
|
|
680
|
-
@mailbox.capture(
|
|
681
|
-
to: to, subject: subject, body: body, html: html,
|
|
682
|
-
cc: cc, bcc: bcc, reply_to: reply_to,
|
|
683
|
-
from_address: @from_address, from_name: @from_name,
|
|
684
|
-
attachments: attachments
|
|
685
|
-
)
|
|
686
|
-
end
|
|
687
|
-
|
|
688
|
-
def test_connection
|
|
689
|
-
{ success: true, message: "DevMailbox mode — no SMTP connection needed" }
|
|
690
|
-
end
|
|
691
|
-
|
|
692
|
-
def inbox(**args) = @mailbox.inbox(**args)
|
|
693
|
-
def read(...) = @mailbox.read(...)
|
|
694
|
-
def unread(...) = @mailbox.unread_count
|
|
695
|
-
def search(**args) = @mailbox.inbox(**args)
|
|
696
|
-
def folders = ["inbox", "outbox"]
|
|
697
|
-
end
|
|
698
721
|
end
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4.rb
CHANGED
|
@@ -127,7 +127,18 @@ module Tina4
|
|
|
127
127
|
autoload :Messenger, File.expand_path("tina4/messenger", __dir__)
|
|
128
128
|
autoload :MessengerError, File.expand_path("tina4/messenger", __dir__)
|
|
129
129
|
autoload :MessengerConnectionError, File.expand_path("tina4/messenger", __dir__)
|
|
130
|
-
|
|
130
|
+
|
|
131
|
+
# Factory for a Messenger configured from the environment.
|
|
132
|
+
#
|
|
133
|
+
# Defined HERE, eagerly, and not in messenger.rb: `autoload` only fires on a
|
|
134
|
+
# CONSTANT reference, and a module function is not a constant. A cold
|
|
135
|
+
# `require "tina4"; Tina4.create_messenger` therefore raised NoMethodError until
|
|
136
|
+
# something else happened to touch Tina4::Messenger first -- the documented entry
|
|
137
|
+
# point was unreachable on a fresh process. Naming the constant below is what
|
|
138
|
+
# triggers the autoload.
|
|
139
|
+
def self.create_messenger(**options)
|
|
140
|
+
Tina4::Messenger.create_messenger(**options)
|
|
141
|
+
end
|
|
131
142
|
autoload :IMAP_CONNECTION_ERRORS, File.expand_path("tina4/messenger", __dir__)
|
|
132
143
|
autoload :DocStore, File.expand_path("tina4/docstore", __dir__)
|
|
133
144
|
autoload :ScssCompiler, File.expand_path("tina4/scss_compiler", __dir__)
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.13.
|
|
4
|
+
version: 3.13.94
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-29 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|