labimotion 2.4.0.rc2 → 2.4.0.rc4

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/lib/labimotion/apis/generic_dataset_api.rb +41 -3
  4. data/lib/labimotion/apis/generic_element_api.rb +60 -8
  5. data/lib/labimotion/apis/generic_klass_api.rb +50 -6
  6. data/lib/labimotion/apis/klass_share_api.rb +646 -0
  7. data/lib/labimotion/apis/labimotion_api.rb +1 -0
  8. data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
  9. data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
  10. data/lib/labimotion/apis/segment_api.rb +61 -10
  11. data/lib/labimotion/constants.rb +14 -0
  12. data/lib/labimotion/entities/application_entity.rb +13 -1
  13. data/lib/labimotion/entities/generic_klass_entity.rb +125 -0
  14. data/lib/labimotion/entities/klass_share_entity.rb +48 -0
  15. data/lib/labimotion/helpers/dataset_helpers.rb +123 -1
  16. data/lib/labimotion/helpers/element_helpers.rb +206 -4
  17. data/lib/labimotion/helpers/generic_helpers.rb +276 -4
  18. data/lib/labimotion/helpers/param_helpers.rb +68 -0
  19. data/lib/labimotion/helpers/segment_helpers.rb +87 -4
  20. data/lib/labimotion/libs/ai_egress_guard.rb +84 -0
  21. data/lib/labimotion/libs/ai_template.rb +1482 -0
  22. data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
  23. data/lib/labimotion/libs/file_extractor.rb +210 -0
  24. data/lib/labimotion/libs/owner_resolver.rb +50 -0
  25. data/lib/labimotion/libs/ownership_audit.rb +73 -0
  26. data/lib/labimotion/libs/share_notifier.rb +114 -0
  27. data/lib/labimotion/libs/share_resolver.rb +369 -0
  28. data/lib/labimotion/models/concerns/template_doi.rb +133 -0
  29. data/lib/labimotion/models/dataset_klass.rb +1 -1
  30. data/lib/labimotion/models/element_klass.rb +1 -1
  31. data/lib/labimotion/models/klass_share.rb +126 -0
  32. data/lib/labimotion/models/segment_klass.rb +1 -1
  33. data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
  34. data/lib/labimotion/usecases/release_template_doi.rb +42 -17
  35. data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
  36. data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
  37. data/lib/labimotion/utils/prop.rb +1 -0
  38. data/lib/labimotion/version.rb +1 -1
  39. data/lib/labimotion.rb +11 -0
  40. metadata +28 -2
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
  require 'grape'
3
- require 'labimotion/models/segment_klass'
3
+ # require 'labimotion/models/segment_klass' — the constant resolves through the gem's
4
+ # autoload table at call time, like the element path; an eager require drags ActiveRecord
5
+ # into any process that only wants the helpers (element_helpers keeps its own model require
6
+ # commented for the same reason).
4
7
  require 'labimotion/utils/utils'
5
8
 
6
9
  module Labimotion
@@ -36,7 +39,13 @@ module Labimotion
36
39
  attributes[:uuid] = uuid
37
40
  attributes[:released_at] = DateTime.now
38
41
  attributes[:properties_release] = attributes[:properties_template]
39
- klass = Labimotion::SegmentKlass.create!(attributes)
42
+ # The owner row shares the create's transaction: a klass committed without it is
43
+ # owner-less, which falls open to the legacy designer-wide gate.
44
+ klass = Labimotion::SegmentKlass.transaction do
45
+ Labimotion::SegmentKlass.create!(attributes).tap do |klz|
46
+ Labimotion::KlassShare.seed_owner!(klz, current_user.id)
47
+ end
48
+ end
40
49
  klass.reload
41
50
  klass.create_klasses_revision(current_user)
42
51
  klass
@@ -45,8 +54,71 @@ module Labimotion
45
54
  raise e
46
55
  end
47
56
 
57
+ # Create a new (inactive) segment klass whose properties template is
58
+ # generated by an LLM from a user-provided label plus optional description,
59
+ # reference links and uploaded files. The parent ElementKlass must be set on
60
+ # @klass by the API (after_validation), mirroring create_segment_klass. The
61
+ # admin reviews/edits the generated template in the designer and activates it.
62
+ def create_ai_segment_klass(current_user, params)
63
+ return { status: 'error', message: 'A parent element class is required.' } if @klass.nil?
64
+
65
+ if Array(params[:files]).size > Labimotion::AiTemplate::MAX_FILES
66
+ return { status: 'error', message: "Too many files (max #{Labimotion::AiTemplate::MAX_FILES})." }
67
+ end
68
+
69
+ overrides = ai_user_overrides(current_user)
70
+ ai = Labimotion::AiTemplate.generate(
71
+ kind: 'segment',
72
+ subject: params[:label],
73
+ desc: params[:desc],
74
+ cols: params[:cols],
75
+ references: params[:references],
76
+ files: params[:files],
77
+ **overrides
78
+ )
79
+
80
+ uuid = SecureRandom.uuid
81
+ # property-base schema requires pkg, uuid, klass, layers, version, identifier.
82
+ properties_template = {
83
+ 'uuid' => uuid,
84
+ 'klass' => 'SegmentKlass',
85
+ 'pkg' => Labimotion::Utils.pkg(nil),
86
+ 'version' => '1.0.0',
87
+ 'identifier' => uuid,
88
+ 'layers' => ai['layers'],
89
+ 'select_options' => ai['select_options'],
90
+ 'metadata' => ai['metadata']
91
+ }
92
+ attributes = {
93
+ 'label' => params[:label],
94
+ 'desc' => params[:desc].presence || ai['label'].presence,
95
+ 'element_klass' => @klass,
96
+ 'place' => 100,
97
+ 'is_active' => false,
98
+ 'uuid' => uuid,
99
+ 'released_at' => DateTime.now,
100
+ 'properties_template' => properties_template,
101
+ 'properties_release' => properties_template,
102
+ 'created_by' => current_user.id
103
+ }
104
+ # Klass-level segment metadata (render kind: own tab vs the host element's
105
+ # Properties tab), mirroring the non-AI create_segment_klass which persists
106
+ # params[:metadata] via declared. Absent -> leave the column default.
107
+ attributes['metadata'] = params[:metadata] if params[:metadata].present?
108
+
109
+ klass = Labimotion::SegmentKlass.create!(attributes)
110
+ klass.reload
111
+ klass.create_klasses_revision(current_user)
112
+ { status: 'success',
113
+ message: "The AI segment template [#{params[:label]}] has been created as inactive. Review and activate it in the designer." }
114
+ rescue StandardError => e
115
+ Labimotion.log_exception(e, current_user)
116
+ { status: 'error', message: e.message }
117
+ end
118
+
48
119
  def update_segment_klass(current_user, params)
49
120
  segment = fetch_klass('SegmentKlass', params[:id])
121
+ authorize_klass!(segment, :write)
50
122
  place = params[:place]
51
123
  begin
52
124
  place = place.to_i if place.present? && place.to_i == place.to_f
@@ -77,7 +149,14 @@ module Labimotion
77
149
  if segment_klass['uuid'] == attributes['uuid'] && segment_klass['version'] == attributes['version']
78
150
  return { status: 'success', message: "This segment: #{attributes['label']} has the latest version!" }
79
151
  else
80
- segment_klass.update!(attributes)
152
+ # Upgrading bumps the version and cuts a revision — :release, owner-only, and the
153
+ # gate lives here because the create/update branch is unknown until the identifier
154
+ # lookup. error! throws rather than raises, so it escapes this method's rescue.
155
+ authorize_klass!(segment_klass, :release)
156
+ # Same reassignment defect as the element path: `upload_klass` merges
157
+ # `created_by: current_user.id` unconditionally (segment_api.rb:147), so upgrading an
158
+ # existing segment template would hand its authorship to the importer.
159
+ segment_klass.update!(attributes.except('created_by', :created_by))
81
160
  segment_klass.create_klasses_revision(current_user)
82
161
  return { status: 'success', message: "This segment: [#{attributes['label']}] has been upgraded to the version: #{attributes['version']}!" }
83
162
  end
@@ -87,7 +166,11 @@ module Labimotion
87
166
  return { status: 'error', message: "The segment [#{attributes['label']}] is already in use." }
88
167
  else
89
168
  attributes['created_by'] = current_user.id
90
- segment_klass = Labimotion::SegmentKlass.create!(attributes)
169
+ segment_klass = Labimotion::SegmentKlass.transaction do
170
+ Labimotion::SegmentKlass.create!(attributes).tap do |klz|
171
+ Labimotion::KlassShare.seed_owner!(klz, current_user.id)
172
+ end
173
+ end
91
174
  segment_klass.create_klasses_revision(current_user)
92
175
  return { status: 'success', message: "The segment: #{attributes['label']} has been created using version: #{attributes['version']}!" }
93
176
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'resolv'
4
+ require 'ipaddr'
5
+ require 'uri'
6
+
7
+ module Labimotion
8
+ # Validates a USER-SUPPLIED AI provider base URL before the server makes an
9
+ # outbound request to it, to prevent SSRF. A user may point the AI feature at
10
+ # their own OpenAI-compatible endpoint, but only at a PUBLIC https host — never
11
+ # at loopback / private / link-local / cloud-metadata addresses.
12
+ #
13
+ # Known limitation: validation resolves DNS at check time; a hostile resolver
14
+ # could rebind between this check and the actual request (TOCTOU). Redirect
15
+ # following is disabled at the HTTP layer, which removes the most common bypass;
16
+ # pinning the connection to the validated IP would close the residual window.
17
+ class AiEgressGuard
18
+ class BlockedError < StandardError; end
19
+
20
+ # Ranges Ruby's IPAddr#private?/#loopback?/#link_local? don't all cover:
21
+ # unspecified, CGNAT, benchmarking/test-net, IPv4-broadcast, reserved,
22
+ # and the IPv6 unique-local / link-local / multicast blocks.
23
+ EXTRA_BLOCKED = [
24
+ '0.0.0.0/8', '100.64.0.0/10', '192.0.0.0/24', '192.0.2.0/24',
25
+ '198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24', '240.0.0.0/4',
26
+ '255.255.255.255/32', '::/128', '::1/128', 'fc00::/7', 'fe80::/10', 'ff00::/8'
27
+ ].map { |cidr| IPAddr.new(cidr) }.freeze
28
+
29
+ # Raise BlockedError unless url is a public https endpoint. Returns url.
30
+ def self.validate!(url)
31
+ uri = parse(url)
32
+ raise BlockedError, 'AI provider URL must use https' unless uri.scheme == 'https'
33
+
34
+ host = uri.host.to_s
35
+ raise BlockedError, 'AI provider URL has no host' if host.empty?
36
+
37
+ addresses = resolve(host)
38
+ raise BlockedError, "AI provider host could not be resolved: #{host}" if addresses.empty?
39
+
40
+ blocked = addresses.find { |ip| !public_ip?(ip) }
41
+ raise BlockedError, "AI provider host resolves to a non-public address (#{blocked})" if blocked
42
+
43
+ url
44
+ end
45
+
46
+ def self.parse(url)
47
+ URI.parse(url.to_s.strip)
48
+ rescue URI::InvalidURIError
49
+ raise BlockedError, 'AI provider URL is not a valid URL'
50
+ end
51
+
52
+ # All A/AAAA addresses for host as IPAddr; a literal IP resolves to itself.
53
+ def self.resolve(host)
54
+ return [IPAddr.new(host)] if literal_ip?(host)
55
+
56
+ Resolv.getaddresses(host).filter_map { |addr| safe_ipaddr(addr) }
57
+ rescue StandardError
58
+ []
59
+ end
60
+
61
+ def self.literal_ip?(str)
62
+ IPAddr.new(str)
63
+ true
64
+ rescue IPAddr::InvalidAddressError
65
+ false
66
+ end
67
+
68
+ def self.safe_ipaddr(addr)
69
+ ip = IPAddr.new(addr.to_s)
70
+ ip.ipv4_mapped? ? ip.native : ip
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
75
+ # Public = not loopback/private/link-local and not in EXTRA_BLOCKED.
76
+ def self.public_ip?(ip)
77
+ return false if ip.loopback? || ip.private? || ip.link_local?
78
+
79
+ EXTRA_BLOCKED.none? { |net| net.include?(ip) }
80
+ rescue StandardError
81
+ false
82
+ end
83
+ end
84
+ end