turbo_chat 0.1.15 → 0.2.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.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +10 -0
  4. data/app/assets/javascripts/turbo_chat/realtime.js +1 -14
  5. data/app/assets/javascripts/turbo_chat/shared.js +1 -0
  6. data/app/controllers/turbo_chat/application_controller.rb +1 -1
  7. data/app/controllers/turbo_chat/chat_memberships_controller.rb +1 -1
  8. data/app/controllers/turbo_chat/chat_messages_controller.rb +1 -1
  9. data/app/controllers/turbo_chat/chats_controller/event_payload_support.rb +2 -2
  10. data/app/controllers/turbo_chat/chats_controller/invitation_support.rb +1 -1
  11. data/app/controllers/turbo_chat/chats_controller.rb +1 -1
  12. data/app/helpers/turbo_chat/application_helper/config_support.rb +1 -1
  13. data/app/helpers/turbo_chat/application_helper/mention_support/permission_support.rb +2 -2
  14. data/app/helpers/turbo_chat/application_helper/message_rendering.rb +9 -0
  15. data/app/helpers/turbo_chat/application_helper/participant_support.rb +2 -2
  16. data/app/models/turbo_chat/chat.rb +4 -0
  17. data/app/models/turbo_chat/chat_message/blocked_words_moderation.rb +3 -3
  18. data/app/models/turbo_chat/chat_message/broadcasting.rb +1 -1
  19. data/app/models/turbo_chat/chat_message/formatting.rb +5 -1
  20. data/app/models/turbo_chat/chat_message/mention_validation.rb +2 -2
  21. data/app/models/turbo_chat/chat_message.rb +1 -1
  22. data/db/migrate/20260302000015_add_kind_index_to_turbo_chat_chat_messages.rb +6 -0
  23. data/lib/turbo_chat/messages.rb +1 -1
  24. data/lib/turbo_chat/moderation/support.rb +1 -1
  25. data/lib/turbo_chat/permission/support.rb +4 -3
  26. data/lib/turbo_chat/permission.rb +1 -1
  27. data/lib/turbo_chat/version.rb +1 -1
  28. metadata +3 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 62e1eb06f88659efb5df1cc8a72f33e669b2a969dd734abc8a977aa4331fe925
4
- data.tar.gz: 5c40f2095d91a51a69cafd0086553ceab8ecf69894696bc8de2f86498170e52a
3
+ metadata.gz: c3fc14508fac1357a1da08a00d2cd3b905271a1fd5a18dc0ccc71e8f6a8572c7
4
+ data.tar.gz: dc7b1fb742ebe03541e5ab140c312d1b2ed6f27efeb0cfbd3adca266bd9f7b28
5
5
  SHA512:
6
- metadata.gz: a0ebcdf8245e4c726f65071d9da1bb673cbef17ae2327c0e3708e86097bedf90a51a9deaef21b9e28b19aef5552f0071b164a21e571a0e332834775dab54db78
7
- data.tar.gz: 7e9ede1f0c631f03642b8c9da45a5ec4a15b0880edc85ae3f6ea51b2aa4aaf59e25d91786b2ab3e9a30e4c4ea890c47deee143910978ebcb74793d20cfa203ed
6
+ metadata.gz: a452a6b3ba2b13ab03382150ca9f5780b13994781011a764f54a518939a095a46dec586319b9853f5e03bc4b7b0e70fc62af9749935540fcc5525534c2c910c6
7
+ data.tar.gz: be545c2e1dc54a1df52fa83e8785ab36ea48c138ce40338c12ebd122e5311d771c861688b1347fe3f3b1f77c646c5f1fa7e1207fd482293c5ef89858a6b39cc4
data/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to `turbo_chat` will be documented in this file.
4
4
 
5
+ ## [0.2.0] - 2026-03-02
6
+
7
+ ### Added
8
+ - Membership lookup cache (`Chat#membership_lookup`) for efficient per-message role resolution, eliminating N+1 queries when rendering participant roles in message lists.
9
+ - Memoized `actor_membership` in Permission to avoid redundant database queries within a single request.
10
+
11
+ ### Changed
12
+ - Narrowed broad `rescue StandardError` clauses to specific exception types (`NoMethodError`, `TypeError`) across controllers, helpers, models, and permission modules for better error visibility during development.
13
+ - Removed duplicated `containerBottomPadding` function from `realtime.js`; now imports the shared version from `shared.js`.
14
+
5
15
  ## [0.1.15] - 2026-02-28
6
16
 
7
17
  ### Added
data/README.md CHANGED
@@ -149,6 +149,10 @@ end
149
149
 
150
150
  Flat aliases (for example `config.max_message_length`) still work for backward compatibility.
151
151
 
152
+ ### Rate Limiting
153
+
154
+ TurboChat does not include built-in rate limiting. For production deployments, add request throttling for message creation in your host application using middleware such as [rack-attack](https://github.com/rack/rack-attack).
155
+
152
156
  ## Message Ingest API
153
157
 
154
158
  Post messages as a specific participant, including external sources like WhatsApp:
@@ -368,6 +372,12 @@ bin/rails turbo_chat:install:migrations
368
372
  bin/rails db:migrate
369
373
  ```
370
374
 
375
+ ## Known Limitations
376
+
377
+ - **Stream subscriptions after member removal.** Turbo Stream subscriptions persist until the page is reloaded. A removed member may continue to see new messages until they navigate away. This is not a security issue because stream names are cryptographically signed, but it can be surprising.
378
+ - **Blocked word filtering.** Word-boundary matching can be bypassed with Unicode homoglyphs, zero-width characters, or leetspeak substitutions. Treat it as a first line of defense, not a guarantee.
379
+ - **Invitable participants cap.** The invite picker returns at most 100 non-member participants. Applications with larger user bases should provide a custom search endpoint.
380
+
371
381
  ## Dependencies
372
382
 
373
383
  - Ruby `>= 3.1`
@@ -15,6 +15,7 @@
15
15
  var parseMentionOptions = namespace.parseMentionOptions;
16
16
  var setupMentionAutocomplete = namespace.setupMentionAutocomplete;
17
17
  var scrollLastMessageIntoView = namespace.scrollLastMessageIntoView;
18
+ var containerBottomPadding = namespace.containerBottomPadding;
18
19
 
19
20
  function hideOwnSignals(container) {
20
21
  if (!container) {
@@ -41,20 +42,6 @@
41
42
  });
42
43
  }
43
44
 
44
- function containerBottomPadding(container) {
45
- if (!container || typeof window === "undefined") {
46
- return 0;
47
- }
48
-
49
- var cssPadding = window.getComputedStyle(container).paddingBottom;
50
- var parsedPadding = parseFloat(cssPadding);
51
- if (isNaN(parsedPadding) || parsedPadding <= 0) {
52
- return 0;
53
- }
54
-
55
- return parsedPadding;
56
- }
57
-
58
45
  function visibleSignalNode(container) {
59
46
  if (!container) {
60
47
  return null;
@@ -524,6 +524,7 @@
524
524
  namespace.emptyMentionAutocomplete = emptyMentionAutocomplete;
525
525
  namespace.setupMentionAutocomplete = setupMentionAutocomplete;
526
526
  namespace.scrollLastMessageIntoView = scrollLastMessageIntoView;
527
+ namespace.containerBottomPadding = containerBottomPadding;
527
528
  namespace.signalOverlayOffset = signalOverlayOffset;
528
529
  namespace.prefersReducedMotion = prefersReducedMotion;
529
530
  namespace.scrollMessageIntoView = scrollMessageIntoView;
@@ -68,7 +68,7 @@ module TurboChat
68
68
  configuration = TurboChat.configuration
69
69
  value = configuration.respond_to?(:disable_input) ? configuration.disable_input : false
70
70
  ActiveModel::Type::Boolean.new.cast(value)
71
- rescue StandardError
71
+ rescue NoMethodError, TypeError
72
72
  false
73
73
  end
74
74
  end
@@ -77,7 +77,7 @@ module TurboChat
77
77
  return false if actor_rank.nil?
78
78
 
79
79
  definition[:rank].to_i <= actor_rank
80
- rescue StandardError
80
+ rescue NoMethodError, TypeError
81
81
  false
82
82
  end
83
83
 
@@ -149,7 +149,7 @@ module TurboChat
149
149
  configuration = TurboChat.configuration
150
150
  value = configuration.respond_to?(method_name) ? configuration.public_send(method_name) : default
151
151
  ActiveModel::Type::Boolean.new.cast(value)
152
- rescue StandardError
152
+ rescue NoMethodError, TypeError
153
153
  default
154
154
  end
155
155
  end
@@ -16,7 +16,7 @@ module TurboChat
16
16
  chatTitle: symbolized_payload[:chatTitle].presence || symbolized_payload[:chat_title].presence,
17
17
  chatMembershipId: symbolized_payload[:chatMembershipId].presence || symbolized_payload[:chat_membership_id].presence
18
18
  }.compact
19
- rescue StandardError
19
+ rescue NoMethodError, TypeError
20
20
  nil
21
21
  end
22
22
 
@@ -37,7 +37,7 @@ module TurboChat
37
37
  chatTitle: symbolized_payload[:chatTitle].presence || symbolized_payload[:chat_title].presence,
38
38
  chatMembershipId: symbolized_payload[:chatMembershipId].presence || symbolized_payload[:chat_membership_id].presence
39
39
  }.compact
40
- rescue StandardError
40
+ rescue NoMethodError, TypeError
41
41
  nil
42
42
  end
43
43
 
@@ -11,7 +11,7 @@ module TurboChat
11
11
 
12
12
  current_member_ids = @chat.chat_memberships.where(removed_at: nil, participant_type: participant_type).pluck(:participant_id)
13
13
  participant_class.where.not(id: current_member_ids).order(id: :asc).limit(100).to_a
14
- rescue StandardError
14
+ rescue NoMethodError, TypeError
15
15
  []
16
16
  end
17
17
 
@@ -101,7 +101,7 @@ module TurboChat
101
101
  configuration = TurboChat.configuration
102
102
  value = configuration.respond_to?(:show_members) ? configuration.show_members : true
103
103
  ActiveModel::Type::Boolean.new.cast(value)
104
- rescue StandardError
104
+ rescue NoMethodError, TypeError
105
105
  true
106
106
  end
107
107
 
@@ -118,7 +118,7 @@ module TurboChat
118
118
  return default unless configuration.respond_to?(method_name)
119
119
 
120
120
  configuration.public_send(method_name)
121
- rescue StandardError
121
+ rescue NoMethodError, TypeError
122
122
  default
123
123
  end
124
124
 
@@ -11,7 +11,7 @@ module TurboChat
11
11
  return nil if participant.nil?
12
12
 
13
13
  TurboChat.configuration.permission_adapter.new(participant, chat)
14
- rescue StandardError
14
+ rescue NoMethodError, TypeError, ArgumentError
15
15
  nil
16
16
  end
17
17
 
@@ -19,7 +19,7 @@ module TurboChat
19
19
  return true unless permission.respond_to?(method_name)
20
20
 
21
21
  permission.public_send(method_name)
22
- rescue StandardError
22
+ rescue NoMethodError, TypeError
23
23
  false
24
24
  end
25
25
  end
@@ -27,6 +27,8 @@ module TurboChat
27
27
 
28
28
  def render_chat_message_body(chat_message)
29
29
  body = chat_message.body.to_s
30
+ # html_safe is safe here: decorate_plain_message_text guarantees the body
31
+ # is html_escaped before any markup injection (see method comment above).
30
32
  return content_tag(:p, decorate_plain_message_text(body).html_safe, class: "chat-body") unless TurboChat.configuration.render_message_html
31
33
 
32
34
  sanitized_html = sanitize(
@@ -103,6 +105,13 @@ module TurboChat
103
105
  "##{red}#{green}#{blue}#{alpha_hex}".downcase
104
106
  end
105
107
 
108
+ # Safety: builds an HTML-safe string via a strict pipeline order:
109
+ # 1. ERB::Util.html_escape escapes ALL user content first
110
+ # 2. Emoji aliases and mention highlights inject only controlled markup
111
+ # 3. Newlines are converted to <br> tags
112
+ # The result is safe to mark as html_safe because no raw user content
113
+ # reaches the output unescaped. Any change to this pipeline MUST
114
+ # preserve html_escape as the first step.
106
115
  def decorate_plain_message_text(body)
107
116
  formatted = ERB::Util.html_escape(body.to_s)
108
117
  formatted = apply_emoji_aliases(formatted) if TurboChat.configuration.enable_emoji_aliases
@@ -57,7 +57,7 @@ module TurboChat
57
57
  return false if permission.respond_to?(:can_post_message?) && !permission.can_post_message?
58
58
 
59
59
  own_chat_message?(chat_message, participant: participant)
60
- rescue StandardError
60
+ rescue NoMethodError, TypeError
61
61
  false
62
62
  end
63
63
 
@@ -67,7 +67,7 @@ module TurboChat
67
67
  return nil unless respond_to?(:current_chat_participant, true)
68
68
 
69
69
  current_chat_participant
70
- rescue StandardError
70
+ rescue NoMethodError, TypeError
71
71
  nil
72
72
  end
73
73
 
@@ -65,6 +65,10 @@ module TurboChat
65
65
  relation.ordered.preload(:participant)
66
66
  end
67
67
 
68
+ def membership_lookup
69
+ @membership_lookup ||= chat_memberships.active.index_by { |m| [m.participant_type, m.participant_id] }
70
+ end
71
+
68
72
  def last_message_at
69
73
  chat_messages.message.maximum(:created_at)
70
74
  end
@@ -57,7 +57,7 @@ module TurboChat
57
57
  return [] unless configuration.respond_to?(:effective_blocked_words)
58
58
 
59
59
  Array(configuration.effective_blocked_words)
60
- rescue StandardError
60
+ rescue NoMethodError, TypeError
61
61
  []
62
62
  end
63
63
 
@@ -66,7 +66,7 @@ module TurboChat
66
66
  return "reject" unless configuration.respond_to?(:effective_blocked_words_action)
67
67
 
68
68
  configuration.effective_blocked_words_action.to_s
69
- rescue StandardError
69
+ rescue NoMethodError, TypeError
70
70
  "reject"
71
71
  end
72
72
 
@@ -92,7 +92,7 @@ module TurboChat
92
92
  return false unless configuration.respond_to?(:emit_blocked_words_events)
93
93
 
94
94
  ActiveModel::Type::Boolean.new.cast(configuration.emit_blocked_words_events)
95
- rescue StandardError
95
+ rescue NoMethodError, TypeError
96
96
  false
97
97
  end
98
98
 
@@ -80,7 +80,7 @@ module TurboChat
80
80
  value = configuration.respond_to?(:message_insert_position) ? configuration.message_insert_position : "append_end"
81
81
  normalized = value.to_s.strip.downcase
82
82
  %w[append_start start prepend].include?(normalized)
83
- rescue StandardError
83
+ rescue NoMethodError, TypeError
84
84
  false
85
85
  end
86
86
  end
@@ -46,7 +46,11 @@ module TurboChat
46
46
  def participant_membership
47
47
  return @participant_membership if instance_variable_defined?(:@participant_membership)
48
48
 
49
- @participant_membership = chat.chat_memberships.active.find_by(participant: participant)
49
+ @participant_membership = if chat.respond_to?(:membership_lookup) && chat.instance_variable_defined?(:@membership_lookup)
50
+ chat.membership_lookup[[participant_type, participant_id]]
51
+ else
52
+ chat.chat_memberships.active.find_by(participant: participant)
53
+ end
50
54
  end
51
55
 
52
56
  def formatted_time_for(timestamp)
@@ -36,7 +36,7 @@ module TurboChat
36
36
  return nil unless adapter.respond_to?(:new)
37
37
 
38
38
  adapter.new(participant, chat)
39
- rescue StandardError
39
+ rescue NoMethodError, TypeError, ArgumentError
40
40
  nil
41
41
  end
42
42
 
@@ -53,7 +53,7 @@ module TurboChat
53
53
  else
54
54
  permission_gate_allowed?(permission, :can_mention_members?)
55
55
  end
56
- rescue StandardError
56
+ rescue NoMethodError, TypeError
57
57
  false
58
58
  end
59
59
 
@@ -98,7 +98,7 @@ module TurboChat
98
98
  return true unless configuration.respond_to?(:system_messages)
99
99
 
100
100
  ActiveModel::Type::Boolean.new.cast(configuration.system_messages)
101
- rescue StandardError
101
+ rescue NoMethodError, TypeError
102
102
  true
103
103
  end
104
104
 
@@ -0,0 +1,6 @@
1
+ class AddKindIndexToTurboChatChatMessages < ActiveRecord::Migration[7.0]
2
+ def change
3
+ add_index :turbo_chat_chat_messages, %i[chat_id kind created_at],
4
+ name: "index_turbo_chat_messages_on_chat_kind_created"
5
+ end
6
+ end
@@ -125,7 +125,7 @@ module TurboChat
125
125
  adapter.new(participant, chat)
126
126
  rescue AuthorizationError
127
127
  raise
128
- rescue StandardError => error
128
+ rescue NoMethodError, TypeError, ArgumentError => error
129
129
  raise AuthorizationError, "Unable to authorize message posting: #{error.message}"
130
130
  end
131
131
  private_class_method :permission_for
@@ -50,7 +50,7 @@ module TurboChat::Moderation
50
50
  return false unless config.respond_to?(:emit_moderation_events)
51
51
 
52
52
  ActiveModel::Type::Boolean.new.cast(config.emit_moderation_events)
53
- rescue StandardError
53
+ rescue NoMethodError, TypeError
54
54
  false
55
55
  end
56
56
 
@@ -36,10 +36,11 @@ class TurboChat::Permission
36
36
  def target_role_rank(target_membership) = target_membership&.effective_role_rank || -1
37
37
 
38
38
  def actor_membership
39
- return nil unless chat_present?
40
- return nil unless participant_present?
39
+ return @actor_membership if defined?(@actor_membership)
41
40
 
42
- chat.chat_memberships.active.find_by(participant: participant)
41
+ @actor_membership = if chat_present? && participant_present?
42
+ chat.chat_memberships.active.find_by(participant: participant)
43
+ end
43
44
  end
44
45
 
45
46
  def participant_present? = !participant.nil?
@@ -73,7 +73,7 @@ class TurboChat::Permission
73
73
  configuration = TurboChat.configuration
74
74
  value = configuration.respond_to?(:disable_input) ? configuration.disable_input : false
75
75
  ActiveModel::Type::Boolean.new.cast(value)
76
- rescue StandardError
76
+ rescue NoMethodError, TypeError
77
77
  false
78
78
  end
79
79
  end
@@ -1,3 +1,3 @@
1
1
  module TurboChat
2
- VERSION = "0.1.15"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turbo_chat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.15
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander Haumer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-28 00:00:00.000000000 Z
11
+ date: 2026-03-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -113,6 +113,7 @@ files:
113
113
  - db/migrate/20260218000012_add_custom_role_key_to_chat_memberships.rb
114
114
  - db/migrate/20260218000013_add_invitation_accepted_to_turbo_chat_chat_memberships.rb
115
115
  - db/migrate/20260223000014_add_source_fields_to_turbo_chat_chat_messages.rb
116
+ - db/migrate/20260302000015_add_kind_index_to_turbo_chat_chat_messages.rb
116
117
  - lib/generators/turbo_chat/install/install_generator.rb
117
118
  - lib/generators/turbo_chat/install/templates/turbo_chat.rb
118
119
  - lib/tasks/turbo_chat_tasks.rake