collavre 0.22.0 → 0.24.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 (221) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  6. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  7. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  8. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  9. data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
  10. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  11. data/app/assets/stylesheets/collavre/landing.css +92 -0
  12. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  13. data/app/assets/stylesheets/collavre/popup.css +4 -2
  14. data/app/assets/stylesheets/collavre/tables.css +91 -0
  15. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  16. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  17. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  18. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  19. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  20. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  21. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  22. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  23. data/app/controllers/collavre/attachments_controller.rb +13 -3
  24. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  25. data/app/controllers/collavre/creatives_controller.rb +47 -103
  26. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  27. data/app/controllers/collavre/landing_controller.rb +5 -2
  28. data/app/controllers/collavre/tasks_controller.rb +13 -4
  29. data/app/controllers/collavre/topics_controller.rb +54 -1
  30. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  31. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  32. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  33. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  34. data/app/errors/collavre/api_error.rb +20 -0
  35. data/app/helpers/collavre/application_helper.rb +1 -0
  36. data/app/helpers/collavre/creatives_helper.rb +30 -8
  37. data/app/javascript/collavre.js +2 -0
  38. data/app/javascript/components/ImageResizer.jsx +9 -3
  39. data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
  40. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  41. data/app/javascript/components/creative_tree_row.js +28 -5
  42. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  43. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  44. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  45. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  46. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  47. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  48. data/app/javascript/controllers/comment_controller.js +5 -4
  49. data/app/javascript/controllers/comment_version_controller.js +2 -1
  50. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  51. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  52. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  53. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  54. data/app/javascript/controllers/comments/form_controller.js +21 -5
  55. data/app/javascript/controllers/comments/list_controller.js +18 -17
  56. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  57. data/app/javascript/controllers/comments/topics_controller.js +61 -8
  58. data/app/javascript/controllers/common_popup_controller.js +2 -2
  59. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  60. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  61. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  62. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  63. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  64. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  65. data/app/javascript/controllers/index.js +7 -1
  66. data/app/javascript/controllers/search_popup_controller.js +17 -1
  67. data/app/javascript/controllers/share_modal_controller.js +4 -3
  68. data/app/javascript/controllers/topic_list_controller.js +78 -0
  69. data/app/javascript/controllers/topic_search_controller.js +2 -1
  70. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  71. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  72. data/app/javascript/creatives/tree_renderer.js +11 -0
  73. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  74. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  75. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  76. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  77. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  78. data/app/javascript/lib/api/api_error.js +108 -0
  79. data/app/javascript/lib/api/queue_manager.js +38 -4
  80. data/app/javascript/lib/common_popup.js +42 -10
  81. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  82. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  83. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  84. data/app/javascript/lib/editor/code_languages.js +173 -0
  85. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  86. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  87. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  88. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  89. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  90. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  91. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  92. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  93. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  94. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  95. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  96. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  97. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  98. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  99. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  100. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  101. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  102. data/app/javascript/lib/turbo_confirm.js +46 -0
  103. data/app/javascript/lib/typo_correction.js +146 -0
  104. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  105. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  106. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  107. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  108. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  109. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  110. data/app/javascript/lib/utils/dialog.js +300 -0
  111. data/app/javascript/lib/utils/markdown.js +154 -67
  112. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  113. data/app/javascript/lib/utils/table_download.js +15 -0
  114. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  115. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  116. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  117. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  118. data/app/javascript/modules/creative_inline_payload.js +86 -0
  119. data/app/javascript/modules/creative_row_editor.js +231 -471
  120. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  121. data/app/javascript/modules/creative_save_queue.js +95 -0
  122. data/app/javascript/modules/creative_tree_dom.js +260 -0
  123. data/app/javascript/modules/export_to_markdown.js +2 -1
  124. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  125. data/app/javascript/modules/slide_view.js +11 -2
  126. data/app/javascript/modules/typo_corrector.js +534 -0
  127. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  128. data/app/jobs/collavre/compress_job.rb +6 -2
  129. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  130. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  131. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  132. data/app/models/collavre/channel.rb +16 -0
  133. data/app/models/collavre/comment/approvable.rb +16 -0
  134. data/app/models/collavre/comment/broadcastable.rb +46 -7
  135. data/app/models/collavre/comment/notifiable.rb +15 -5
  136. data/app/models/collavre/comment.rb +87 -32
  137. data/app/models/collavre/comment_version.rb +1 -1
  138. data/app/models/collavre/creative/describable.rb +104 -19
  139. data/app/models/collavre/creative/linkable.rb +9 -0
  140. data/app/models/collavre/creative/permissible.rb +97 -49
  141. data/app/models/collavre/creative.rb +73 -5
  142. data/app/models/collavre/creative_share.rb +78 -26
  143. data/app/models/collavre/inbox_item.rb +2 -2
  144. data/app/models/collavre/task.rb +27 -0
  145. data/app/models/collavre/user.rb +90 -10
  146. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  147. data/app/services/collavre/agent_type_classifier.rb +23 -0
  148. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  149. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  150. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  151. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  152. data/app/services/collavre/ai_client.rb +81 -12
  153. data/app/services/collavre/auto_theme_generator.rb +15 -11
  154. data/app/services/collavre/creatives/children_index.rb +86 -0
  155. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  156. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  157. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  158. data/app/services/collavre/creatives/index_query.rb +85 -16
  159. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  160. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  161. data/app/services/collavre/creatives/tree_builder.rb +107 -78
  162. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  163. data/app/services/collavre/http_client.rb +119 -0
  164. data/app/services/collavre/inbox_reply_service.rb +5 -0
  165. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  166. data/app/services/collavre/markdown_converter.rb +13 -3
  167. data/app/services/collavre/markdown_importer.rb +30 -6
  168. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  169. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  170. data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
  171. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  172. data/app/services/collavre/orchestration/matcher.rb +79 -4
  173. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  174. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  175. data/app/services/collavre/system_events/context_builder.rb +1 -14
  176. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  177. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  178. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  179. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  180. data/app/services/collavre/typo_corrector.rb +188 -0
  181. data/app/views/collavre/comments/_comment.html.erb +9 -4
  182. data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
  183. data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
  184. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  185. data/app/views/collavre/creatives/index.html.erb +14 -1
  186. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  187. data/app/views/collavre/landing/show.html.erb +52 -10
  188. data/app/views/collavre/users/show.html.erb +3 -0
  189. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  190. data/app/views/layouts/collavre/slide.html.erb +1 -0
  191. data/config/locales/comments.en.yml +16 -0
  192. data/config/locales/comments.ko.yml +16 -0
  193. data/config/locales/creatives.en.yml +6 -0
  194. data/config/locales/creatives.ko.yml +5 -0
  195. data/config/locales/integrations.en.yml +1 -1
  196. data/config/locales/integrations.ko.yml +1 -1
  197. data/config/locales/landing.en.yml +22 -10
  198. data/config/locales/landing.ko.yml +22 -10
  199. data/config/locales/mobile.en.yml +16 -0
  200. data/config/locales/mobile.ko.yml +16 -0
  201. data/config/locales/orchestration.en.yml +1 -0
  202. data/config/locales/orchestration.ko.yml +1 -0
  203. data/config/locales/users.en.yml +15 -0
  204. data/config/locales/users.ko.yml +15 -0
  205. data/config/routes.rb +13 -0
  206. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  207. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  208. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  209. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  210. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  211. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  212. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  213. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  214. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  215. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  216. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  217. data/db/seeds.rb +51 -0
  218. data/lib/collavre/version.rb +1 -1
  219. data/lib/generators/collavre/install/install_generator.rb +1 -0
  220. metadata +96 -2
  221. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -12,85 +12,99 @@ module Creatives
12
12
  @max_level = max_level
13
13
  @allowed_creative_ids = allowed_creative_ids
14
14
  @progress_map = progress_map
15
- @permission_cache = {}
15
+ @permission_rank = {}
16
16
  end
17
17
 
18
18
  def build(collection, level: 1)
19
19
  return [] if collection.blank?
20
20
 
21
- creatives = Array(collection)
22
- # Preload permissions for all creatives in this batch to avoid N+1
23
- preload_permissions(creatives)
24
-
25
- build_nodes(creatives, level: level)
21
+ build_nodes(Array(collection), level: level)
26
22
  end
27
23
 
28
24
  private
29
25
 
30
- attr_reader :user, :view_context, :raw_params, :expanded_state_map, :select_mode, :max_level, :allowed_creative_ids, :progress_map, :permission_cache
26
+ attr_reader :user, :view_context, :raw_params, :expanded_state_map, :select_mode, :max_level, :allowed_creative_ids, :progress_map
31
27
 
32
- def preload_permissions(creatives)
33
- return unless user
28
+ def children_index
29
+ @children_index ||= ChildrenIndex.new(
30
+ user: user,
31
+ show_archived: raw_params["show_archived"].present?,
32
+ allowed_creative_ids: allowed_creative_ids
33
+ )
34
+ end
35
+
36
+ def comment_badge_index
37
+ @comment_badge_index ||= CommentBadgeIndex.new(user: user)
38
+ end
34
39
 
35
- creative_ids = creatives.map(&:id)
36
- return if creative_ids.empty?
40
+ # Everything a level of nodes needs, resolved in a fixed number of queries
41
+ # rather than a handful per node. The recursion re-enters here for each level,
42
+ # so total cost tracks tree *depth*, not node count.
43
+ def prepare_level(creatives)
44
+ ActiveRecord::Associations::Preloader.new(
45
+ records: creatives,
46
+ associations: [ :origin, { tags: :label } ]
47
+ ).call
37
48
 
38
- # Skip if already cached
39
- uncached_ids = creative_ids - @permission_cache.keys
40
- return if uncached_ids.empty?
49
+ preload_permissions(creatives)
50
+ preload_comment_badges(creatives)
41
51
 
42
- write_rank = CreativeShare.permissions[:write]
52
+ return if children_suppressed?
43
53
 
44
- # Batch query for user-specific permissions
45
- user_permissions = CreativeSharesCache
46
- .where(creative_id: uncached_ids, user_id: user.id)
47
- .pluck(:creative_id, :permission)
54
+ children_index.index(creatives)
55
+ expanding = creatives.select { |creative| load_children_now?(creative) }
56
+ children_index.load(expanding) if expanding.any?
57
+ end
48
58
 
49
- user_permissions.each do |cid, perm|
50
- perm_rank = CreativeSharesCache.permissions[perm]
51
- @permission_cache[cid] = perm_rank && perm_rank >= write_rank && perm_rank != CreativeSharesCache.permissions[:no_access]
52
- end
59
+ # Batch effective-origin permission ranks via the single PermissionFilter
60
+ # read path (owner wins, then the user's own cache entry incl. a no_access
61
+ # deny, then the public entry). Every pending creative gets an explicit
62
+ # entry — nil when no share resolves — so #allowed? can trust
63
+ # @permission_rank.key? and never falls back per-item for absent ids.
64
+ def preload_permissions(creatives)
65
+ pending = creatives.reject { |creative| @permission_rank.key?(creative.id) }
66
+ return if pending.empty?
53
67
 
54
- # For remaining uncached, check public shares
55
- still_uncached = uncached_ids - @permission_cache.keys
56
- if still_uncached.any?
57
- public_permissions = CreativeSharesCache
58
- .where(creative_id: still_uncached, user_id: nil)
59
- .pluck(:creative_id, :permission)
60
-
61
- public_permissions.each do |cid, perm|
62
- next if @permission_cache.key?(cid)
63
- perm_rank = CreativeSharesCache.permissions[perm]
64
- @permission_cache[cid] = perm_rank && perm_rank >= write_rank && perm_rank != CreativeSharesCache.permissions[:no_access]
65
- end
66
- end
68
+ ranks = PermissionFilter.new(user: user).ranks_for(pending.map(&:id))
69
+ pending.each { |creative| @permission_rank[creative.id] = ranks[creative.id] }
70
+ end
67
71
 
68
- # Mark owned creatives as having write permission
69
- owned_ids = creatives.select { |c| c.user_id == user.id }.map(&:id)
70
- owned_ids.each { |cid| @permission_cache[cid] = true }
72
+ # Equivalent to `creative.has_permission?(user, permission)`, served from the
73
+ # level's batched ranks. Falls back for a creative the batch never saw.
74
+ def allowed?(creative, permission)
75
+ return creative.has_permission?(user, permission) unless @permission_rank.key?(creative.id)
71
76
 
72
- # Default to false for any remaining uncached
73
- uncached_ids.each { |cid| @permission_cache[cid] ||= false }
77
+ rank = @permission_rank[creative.id]
78
+ return false if rank.nil?
79
+
80
+ rank >= CreativeShare.permissions[permission.to_s]
74
81
  end
75
82
 
76
- def cached_can_write?(creative)
83
+ def can_write?(creative)
77
84
  return false unless user
78
- # GitHub-synced creatives are always read-only
79
- return false if creative.github_markdown?
80
-
81
- if @permission_cache.key?(creative.id)
82
- @permission_cache[creative.id]
83
- else
84
- # Fallback to individual check if not cached
85
- creative.has_permission?(user, :write)
86
- end
85
+ # Read-only-source creatives are never writable
86
+ return false if creative.read_only_source?
87
+
88
+ allowed?(creative, :write)
89
+ end
90
+
91
+ def can_feedback?(creative)
92
+ allowed?(creative, :feedback)
93
+ end
94
+
95
+ # Only rows that will actually render a badge are worth batching.
96
+ def preload_comment_badges(creatives)
97
+ visible = creatives.reject(&:archived?).select { |creative| can_feedback?(creative) }
98
+ return if visible.empty?
99
+
100
+ comment_badge_index.index(visible.map(&:effective_origin))
87
101
  end
88
102
 
89
103
  def build_nodes(creatives, level:)
90
104
  return [] if level > max_level
105
+ return [] if creatives.empty?
91
106
 
92
- # Preload permissions for this batch of creatives
93
- preload_permissions(creatives) if creatives.any?
107
+ prepare_level(creatives)
94
108
 
95
109
  creatives.flat_map do |creative|
96
110
  build_nodes_for_creative(creative, level: level)
@@ -102,16 +116,18 @@ module Creatives
102
116
  creative.filtered_progress = progress_map[creative.id.to_s]
103
117
  end
104
118
 
105
- filtered_children = filtered_children_for(creative)
106
119
  expanded = expanded?(creative.id)
107
120
  skip = skip_creative?(creative)
108
121
  child_level = level + 1
109
122
  child_render_level = skip ? level : child_level
110
- load_children_now = filters_applied? || expanded || skip
111
- children_nodes = load_children_now ? build_nodes(filtered_children, level: child_render_level) : []
123
+ load_children_now = load_children_now?(creative)
124
+ has_children = !children_suppressed? && children_index.has_children?(creative)
125
+ children_nodes = load_children_now ? build_nodes(children_index.children_for(creative), level: child_render_level) : []
112
126
 
113
127
  return children_nodes if skip
114
128
 
129
+ can_write = can_write?(creative)
130
+
115
131
  [
116
132
  {
117
133
  id: creative.id,
@@ -119,19 +135,22 @@ module Creatives
119
135
  parent_id: creative.parent_id,
120
136
  level: level,
121
137
  select_mode: !!select_mode,
122
- can_write: cached_can_write?(creative),
123
- has_children: filtered_children.any?,
138
+ can_write: can_write,
139
+ has_children: has_children,
124
140
  expanded: expanded,
125
- is_root: creative.parent.nil?,
141
+ is_root: creative.parent_id.nil?,
126
142
  archived: creative.archived?,
127
- github_source: creative.github_markdown?,
143
+ # `github_source` is the wire key the tree renderer JS reads; its value
144
+ # is now the neutral read-only-source flag (GitHub-synced content is one
145
+ # such source) so core names no vendor here.
146
+ github_source: creative.read_only_source?,
128
147
  sequence: creative.sequence,
129
148
  link_url: view_context.collavre.creative_path(creative),
130
- templates: template_payload_for(creative, has_children: filtered_children.any?),
131
- inline_editor_payload: inline_editor_payload_for(creative, can_write: cached_can_write?(creative)),
149
+ templates: template_payload_for(creative, has_children: has_children, can_write: can_write),
150
+ inline_editor_payload: inline_editor_payload_for(creative, can_write: can_write),
132
151
  children_container: children_container_payload(
133
152
  creative,
134
- filtered_children,
153
+ has_children: has_children,
135
154
  child_level: child_level,
136
155
  children_nodes: children_nodes,
137
156
  expanded: expanded,
@@ -149,16 +168,17 @@ module Creatives
149
168
  false
150
169
  end
151
170
 
152
- def filtered_children_for(creative)
153
- return [] if raw_params["comment"] == "true" || (raw_params["search"].present? && raw_params["search_mode"] != "tree")
171
+ # The chats feed and flat search render rows, not a tree: no node has children
172
+ # there, so the whole child resolution is skipped.
173
+ def children_suppressed?
174
+ return @children_suppressed if defined?(@children_suppressed)
154
175
 
155
- children = creative.children_with_permission(user)
156
- children = children.select { |c| !c.archived? } unless raw_params["show_archived"]
157
- if allowed_creative_ids
158
- children.select { |c| allowed_creative_ids.include?(c.id.to_s) }
159
- else
160
- children
161
- end
176
+ @children_suppressed = raw_params["comment"] == "true" ||
177
+ (raw_params["search"].present? && raw_params["search_mode"] != "tree")
178
+ end
179
+
180
+ def load_children_now?(creative)
181
+ filters_applied? || expanded?(creative.id) || skip_creative?(creative)
162
182
  end
163
183
 
164
184
  def expanded?(creative_id)
@@ -174,9 +194,17 @@ module Creatives
174
194
  end
175
195
  end
176
196
 
177
- def template_payload_for(creative, has_children: nil)
197
+ def template_payload_for(creative, has_children: nil, can_write: nil)
178
198
  description_html = view_context.embed_youtube_iframe(creative.effective_description(raw_params["tags"]&.first))
179
- progress_html = view_context.render_creative_progress(creative, select_mode: !!select_mode, has_children: has_children)
199
+ can_feedback = can_feedback?(creative)
200
+ progress_html = view_context.render_creative_progress(
201
+ creative,
202
+ select_mode: !!select_mode,
203
+ has_children: has_children,
204
+ can_write: can_write,
205
+ can_feedback: can_feedback,
206
+ unread_count: can_feedback ? comment_badge_index.unread_count_for(creative.effective_origin) : nil
207
+ )
180
208
 
181
209
  {
182
210
  description_html: description_html,
@@ -189,18 +217,19 @@ module Creatives
189
217
 
190
218
  def inline_editor_payload_for(creative, can_write:)
191
219
  effective = creative.effective_origin(Set.new)
192
- origin_writable = effective.id == creative.id ? can_write : creative.has_permission?(user, :write)
220
+ origin_writable = effective.id == creative.id ? can_write : allowed?(creative, :write)
193
221
  {
194
222
  description_raw_html: creative.effective_description(nil, true),
195
223
  progress: creative.progress,
196
224
  origin_id: creative.origin_id,
197
225
  content_type: effective.data&.dig("content_type"),
198
- markdown_source: origin_writable ? effective.data&.dig("markdown_source") : nil
226
+ markdown_source: origin_writable ? effective.data&.dig("markdown_source") : nil,
227
+ markdown_editor: effective.data&.dig("editor")
199
228
  }
200
229
  end
201
230
 
202
- def children_container_payload(creative, filtered_children, child_level:, children_nodes:, expanded:, load_children_now:)
203
- return nil unless filtered_children.any?
231
+ def children_container_payload(creative, has_children:, child_level:, children_nodes:, expanded:, load_children_now:)
232
+ return nil unless has_children
204
233
 
205
234
  {
206
235
  id: "creative-children-#{creative.id}",
@@ -55,7 +55,7 @@ module Collavre
55
55
  def default_client
56
56
  AiClient.new(
57
57
  vendor: "google",
58
- model: "gemini-3-flash-preview",
58
+ model: "gemini-3.1-flash-lite",
59
59
  system_prompt: nil
60
60
  )
61
61
  end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Collavre
8
+ # Thin, dependency-free HTTP wrapper over Net::HTTP shared across engines.
9
+ #
10
+ # It centralizes timeout configuration, TLS negotiation and transport-error
11
+ # handling so vendor engines no longer each hand-roll their own Net::HTTP /
12
+ # HTTParty / Faraday setup. Callers keep full ownership of their own response
13
+ # parsing and domain error mapping — this wrapper only performs the request and
14
+ # returns a small Response value object.
15
+ #
16
+ # Transport-layer failures (DNS, connection refused/reset, TLS handshake,
17
+ # open/read timeouts) are wrapped in Collavre::HttpClient::ConnectionError so a
18
+ # caller can rescue a single, stable error type regardless of the underlying
19
+ # exception class.
20
+ class HttpClient
21
+ class Error < StandardError; end
22
+ class ConnectionError < Error; end
23
+
24
+ DEFAULT_OPEN_TIMEOUT = 10
25
+ DEFAULT_READ_TIMEOUT = 30
26
+
27
+ # Transport-level exceptions that occur before any HTTP response exists.
28
+ TRANSPORT_ERRORS = [
29
+ SocketError, SystemCallError, Timeout::Error, IOError,
30
+ OpenSSL::SSL::SSLError, Net::OpenTimeout, Net::ReadTimeout
31
+ ].freeze
32
+
33
+ REQUEST_CLASSES = {
34
+ get: Net::HTTP::Get,
35
+ post: Net::HTTP::Post,
36
+ patch: Net::HTTP::Patch,
37
+ put: Net::HTTP::Put,
38
+ delete: Net::HTTP::Delete
39
+ }.freeze
40
+
41
+ # A minimal, read-only view over a Net::HTTP response.
42
+ class Response
43
+ attr_reader :code, :message, :body, :headers
44
+
45
+ def initialize(net_response)
46
+ @net_response = net_response
47
+ @code = net_response.code.to_i
48
+ @message = net_response.message
49
+ @body = net_response.body
50
+ @headers = net_response.to_hash
51
+ end
52
+
53
+ def success?
54
+ @net_response.is_a?(Net::HTTPSuccess)
55
+ end
56
+
57
+ # Parse the response body as JSON, or nil when the body is empty.
58
+ def json
59
+ return nil if body.nil? || body.empty?
60
+
61
+ JSON.parse(body)
62
+ end
63
+ end
64
+
65
+ def initialize(open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT, default_headers: {})
66
+ @open_timeout = open_timeout
67
+ @read_timeout = read_timeout
68
+ @default_headers = default_headers
69
+ end
70
+
71
+ def get(url, headers: {})
72
+ request(:get, url, headers: headers)
73
+ end
74
+
75
+ def post(url, body: nil, headers: {})
76
+ request(:post, url, body: body, headers: headers)
77
+ end
78
+
79
+ def patch(url, body: nil, headers: {})
80
+ request(:patch, url, body: body, headers: headers)
81
+ end
82
+
83
+ def put(url, body: nil, headers: {})
84
+ request(:put, url, body: body, headers: headers)
85
+ end
86
+
87
+ def delete(url, headers: {})
88
+ request(:delete, url, headers: headers)
89
+ end
90
+
91
+ private
92
+
93
+ def request(method, url, body: nil, headers: {})
94
+ uri = URI.parse(url)
95
+ http = Net::HTTP.new(uri.host, uri.port)
96
+ http.use_ssl = uri.scheme == "https"
97
+ http.open_timeout = @open_timeout
98
+ http.read_timeout = @read_timeout
99
+
100
+ req = build_request(method, uri, body, headers)
101
+ Response.new(http.request(req))
102
+ rescue *TRANSPORT_ERRORS => e
103
+ raise ConnectionError, "#{e.class}: #{e.message}"
104
+ end
105
+
106
+ def build_request(method, uri, body, headers)
107
+ path = uri.path.presence || "/"
108
+ path = "#{path}?#{uri.query}" if uri.query.present?
109
+
110
+ request_class = REQUEST_CLASSES.fetch(method) do
111
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
112
+ end
113
+ req = request_class.new(path)
114
+ @default_headers.merge(headers).each { |key, value| req[key] = value }
115
+ req.body = body unless body.nil?
116
+ req
117
+ end
118
+ end
119
+ end
@@ -64,6 +64,11 @@ module Collavre
64
64
  content: @comment.content,
65
65
  user: @comment.user,
66
66
  quoted_comment: original,
67
+ # quoted_comment is set only for linkage to the message being answered.
68
+ # Mark it as a "question" so review_message? stays false — otherwise the
69
+ # agent's response would update the quoted comment in place (the review
70
+ # flow) instead of posting a new reply. See Comment#review_message?.
71
+ review_type: :question,
67
72
  private: false
68
73
  )
69
74
 
@@ -1,5 +1,5 @@
1
1
  module Collavre
2
- require "open-uri"
2
+ require "net/http"
3
3
  require "nokogiri"
4
4
  require "uri"
5
5
  require "ipaddr"
@@ -29,18 +29,22 @@ module Collavre
29
29
  IPAddr.new("ff00::/8")
30
30
  ].freeze
31
31
 
32
+ # Minimal HTTP result the fetcher reasons about. `body` is only populated for
33
+ # successful (2xx) responses; redirects carry a `location` instead.
34
+ Response = Struct.new(:code, :content_type, :body, :location, keyword_init: true)
35
+
32
36
  def self.fetch(url)
33
37
  new(url).fetch
34
38
  end
35
39
 
36
- def initialize(url, io_opener: URI, logger: Rails.logger)
40
+ def initialize(url, http_client: nil, logger: Rails.logger)
37
41
  @url = url
38
- @io_opener = io_opener
42
+ @http_client = http_client || PinnedHttpClient.new
39
43
  @logger = logger
40
44
  end
41
45
 
42
46
  def fetch
43
- uri = safe_http_uri
47
+ uri = parse_http_uri(@url)
44
48
  return {} unless uri
45
49
 
46
50
  html, base_uri = read_html(uri)
@@ -56,47 +60,53 @@ module Collavre
56
60
  private
57
61
 
58
62
  def read_html(uri, redirect_limit = MAX_REDIRECTS)
59
- html = nil
60
- base_uri = nil
61
- options = REQUEST_OPTIONS.merge(read_timeout: READ_TIMEOUT, open_timeout: OPEN_TIMEOUT, redirect: false)
62
- @io_opener.open(uri.to_s, options) do |io|
63
- content_type = io.respond_to?(:content_type) ? io.content_type : nil
64
- if content_type && HTML_CONTENT_TYPES.none? { |type| content_type.include?(type) }
65
- return [ nil, nil ]
66
- end
67
- base_uri = io.respond_to?(:base_uri) ? io.base_uri : uri
68
- html = io.read(MAX_BYTES)
63
+ # Resolve+validate the host ONCE per hop and pin the request to that exact
64
+ # IP. This closes the DNS-rebinding TOCTOU gap: a hostname that validates
65
+ # as public here can no longer be re-resolved to a private/metadata IP at
66
+ # connect time, because the connection targets the pinned address (with the
67
+ # original hostname preserved for Host/SNI).
68
+ addresses = safe_addresses(uri)
69
+ return [ nil, nil ] if addresses.empty?
70
+
71
+ response = fetch_via_pinned_addresses(uri, addresses)
72
+ return [ nil, nil ] unless response
73
+
74
+ if redirect?(response.code)
75
+ return [ nil, nil ] if redirect_limit <= 0
76
+
77
+ redirected_uri = normalize_redirect_uri(uri, response.location)
78
+ return [ nil, nil ] unless redirected_uri
79
+
80
+ # Recurse so the new host is resolved+validated+pinned from scratch;
81
+ # never follow a hop to an internal address.
82
+ return read_html(redirected_uri, redirect_limit - 1)
69
83
  end
70
- [ html, base_uri ]
71
- rescue OpenURI::HTTPRedirect => e
72
- return [ nil, nil ] if redirect_limit <= 0
73
84
 
74
- redirected_uri = safe_redirect_uri(uri, e.uri)
75
- return [ nil, nil ] unless redirected_uri
85
+ return [ nil, nil ] unless success?(response.code)
76
86
 
77
- read_html(redirected_uri, redirect_limit - 1)
78
- rescue OpenURI::HTTPError, SocketError, IOError, SystemCallError, URI::InvalidURIError => e
87
+ content_type = response.content_type
88
+ if content_type && HTML_CONTENT_TYPES.none? { |type| content_type.include?(type) }
89
+ return [ nil, nil ]
90
+ end
91
+
92
+ [ response.body, uri ]
93
+ rescue SocketError, IOError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout,
94
+ OpenSSL::SSL::SSLError, URI::InvalidURIError => e
79
95
  @logger&.info("Link preview fetch skipped for #{@url}: #{e.class} #{e.message}")
80
96
  [ nil, nil ]
81
97
  end
82
98
 
83
- def safe_http_uri
84
- uri = parse_http_uri(@url)
85
- return unless uri
86
- return unless allowed_destination?(uri)
87
-
88
- uri
99
+ def redirect?(code)
100
+ code.to_i.between?(300, 399)
89
101
  end
90
102
 
91
- def safe_redirect_uri(current_uri, redirected)
92
- new_uri = normalize_redirect_uri(current_uri, redirected)
93
- return unless new_uri
94
- return unless allowed_destination?(new_uri)
95
-
96
- new_uri
103
+ def success?(code)
104
+ code.to_i.between?(200, 299)
97
105
  end
98
106
 
99
107
  def normalize_redirect_uri(current_uri, redirected)
108
+ return if redirected.blank?
109
+
100
110
  target_uri = redirected.is_a?(URI) ? redirected : URI.parse(redirected.to_s)
101
111
  target_uri = current_uri.merge(target_uri) if target_uri.relative?
102
112
  return unless %w[http https].include?(target_uri.scheme)
@@ -116,15 +126,45 @@ module Collavre
116
126
  nil
117
127
  end
118
128
 
119
- def allowed_destination?(uri)
129
+ # Resolve the host, reject if it maps to ANY unsafe address, and return every
130
+ # validated public IP to pin against. Rejecting when any resolved address is
131
+ # unsafe (not just the one we pick) keeps defense-in-depth against
132
+ # split-horizon DNS returning a mix of public and private records. Returning
133
+ # all safe addresses lets the caller fall back to the next one when the first
134
+ # is unreachable (e.g. an AAAA record with no IPv6 egress).
135
+ def safe_addresses(uri)
120
136
  host = uri.hostname
121
- return false if host.nil? || host.empty?
137
+ return [] if host.nil? || host.empty?
122
138
 
123
139
  addresses = resolve_addresses(host)
124
- return false if addresses.empty?
125
- return false if addresses.any? { |address| unsafe_ip?(address) }
140
+ return [] if addresses.empty?
141
+ return [] if addresses.any? { |address| unsafe_ip?(address) }
126
142
 
127
- true
143
+ addresses
144
+ end
145
+
146
+ # Try each pre-validated address in turn, pinning the connection to it, and
147
+ # return the first response we can actually open. Connection-level failures
148
+ # fall through to the next candidate; if none connect, the caller treats it
149
+ # as an empty preview. Every address here already passed safe_addresses, so
150
+ # falling back never targets an unsafe IP.
151
+ def fetch_via_pinned_addresses(uri, addresses)
152
+ last_error = nil
153
+ addresses.each do |address|
154
+ return @http_client.get(
155
+ uri,
156
+ ip: address,
157
+ headers: REQUEST_OPTIONS,
158
+ open_timeout: OPEN_TIMEOUT,
159
+ read_timeout: READ_TIMEOUT,
160
+ max_bytes: MAX_BYTES
161
+ )
162
+ rescue SocketError, IOError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout,
163
+ OpenSSL::SSL::SSLError => e
164
+ last_error = e
165
+ end
166
+ @logger&.info("Link preview fetch skipped for #{@url}: #{last_error.class} #{last_error.message}") if last_error
167
+ nil
128
168
  end
129
169
 
130
170
  def resolve_addresses(host)
@@ -226,5 +266,56 @@ module Collavre
226
266
 
227
267
  text.to_s.gsub(/\s+/, " ").strip
228
268
  end
269
+
270
+ # Performs a single (non-redirect-following) GET against a pre-resolved IP
271
+ # while keeping the original hostname for the Host header and TLS SNI/cert
272
+ # verification. Net::HTTP#ipaddr= pins the socket to `ip`, so no second DNS
273
+ # lookup happens between validation and connect. Enforces the caller's open/
274
+ # read timeouts and byte cap.
275
+ class PinnedHttpClient
276
+ def get(uri, ip:, headers:, open_timeout:, read_timeout:, max_bytes:)
277
+ # Pass an explicit nil proxy: Net::HTTP.new defaults p_addr to :ENV, so a
278
+ # set http_proxy/HTTP_PROXY would route through the proxy, which resolves
279
+ # the hostname itself and defeats `http.ipaddr = ip` pinning — reopening
280
+ # the DNS-rebinding/SSRF gap. nil forces a direct, pinned connection.
281
+ http = Net::HTTP.new(uri.hostname, uri.port, nil)
282
+ http.use_ssl = uri.scheme == "https"
283
+ http.ipaddr = ip
284
+ http.open_timeout = open_timeout
285
+ http.read_timeout = read_timeout
286
+
287
+ request = Net::HTTP::Get.new(uri, headers)
288
+ response_struct = nil
289
+
290
+ http.start do |conn|
291
+ conn.request(request) do |response|
292
+ body = read_capped_body(response, max_bytes)
293
+ response_struct = Response.new(
294
+ code: response.code.to_i,
295
+ content_type: response.content_type,
296
+ body: body,
297
+ location: response["location"]
298
+ )
299
+ end
300
+ end
301
+
302
+ response_struct
303
+ ensure
304
+ http&.finish if http&.started?
305
+ end
306
+
307
+ private
308
+
309
+ def read_capped_body(response, max_bytes)
310
+ return nil unless response.is_a?(Net::HTTPSuccess)
311
+
312
+ body = +""
313
+ response.read_body do |chunk|
314
+ body << chunk
315
+ break if body.bytesize >= max_bytes
316
+ end
317
+ body.byteslice(0, max_bytes)
318
+ end
319
+ end
229
320
  end
230
321
  end
@@ -48,12 +48,22 @@ module Collavre
48
48
  source
49
49
  end
50
50
 
51
- # Render with commonmarker (GFM extensions: table, strikethrough, autolink, tasklist, tagfilter)
51
+ # Render with commonmarker (GFM extensions: table, strikethrough, autolink, tasklist, tagfilter).
52
+ # hardbreaks: a single newline becomes <br>, mirroring the JS renderer (marked breaks:true)
53
+ # and the canonical markdown_source, which stores consecutive rich-editor lines one-per-line
54
+ # rather than separated by a blank line.
55
+ #
56
+ # syntax_highlighter: nil disables comrak's built-in syntect highlighter, which otherwise
57
+ # bakes a fixed base16-ocean.dark theme into the stored HTML as inline `style=` attributes
58
+ # (e.g. `<pre style="background-color:#2b303b"><span style="color:#bf616a">`). Those inline
59
+ # styles override our theme-aware code_highlight.css palette and are blind to light/dark mode.
60
+ # Disabling it emits a plain `<pre lang="ruby"><code>…</code></pre>`; the client re-tokenizes
61
+ # with highlight.js so the rendered creative matches the editor (and respects the theme).
52
62
  html = Commonmarker.to_html(input, options: {
53
63
  parse: { smart: true },
54
- render: { unsafe: true },
64
+ render: { unsafe: true, hardbreaks: true },
55
65
  extension: { table: true, strikethrough: true, autolink: true, tasklist: true, tagfilter: true }
56
- })
66
+ }, plugins: { syntax_highlighter: nil })
57
67
 
58
68
  html.strip!
59
69
  html