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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/lib/labimotion/apis/generic_dataset_api.rb +41 -3
- data/lib/labimotion/apis/generic_element_api.rb +60 -8
- data/lib/labimotion/apis/generic_klass_api.rb +50 -6
- data/lib/labimotion/apis/klass_share_api.rb +646 -0
- data/lib/labimotion/apis/labimotion_api.rb +1 -0
- data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
- data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
- data/lib/labimotion/apis/segment_api.rb +61 -10
- data/lib/labimotion/constants.rb +14 -0
- data/lib/labimotion/entities/application_entity.rb +13 -1
- data/lib/labimotion/entities/generic_klass_entity.rb +125 -0
- data/lib/labimotion/entities/klass_share_entity.rb +48 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +123 -1
- data/lib/labimotion/helpers/element_helpers.rb +206 -4
- data/lib/labimotion/helpers/generic_helpers.rb +276 -4
- data/lib/labimotion/helpers/param_helpers.rb +68 -0
- data/lib/labimotion/helpers/segment_helpers.rb +87 -4
- data/lib/labimotion/libs/ai_egress_guard.rb +84 -0
- data/lib/labimotion/libs/ai_template.rb +1482 -0
- data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
- data/lib/labimotion/libs/file_extractor.rb +210 -0
- data/lib/labimotion/libs/owner_resolver.rb +50 -0
- data/lib/labimotion/libs/ownership_audit.rb +73 -0
- data/lib/labimotion/libs/share_notifier.rb +114 -0
- data/lib/labimotion/libs/share_resolver.rb +369 -0
- data/lib/labimotion/models/concerns/template_doi.rb +133 -0
- data/lib/labimotion/models/dataset_klass.rb +1 -1
- data/lib/labimotion/models/element_klass.rb +1 -1
- data/lib/labimotion/models/klass_share.rb +126 -0
- data/lib/labimotion/models/segment_klass.rb +1 -1
- data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
- data/lib/labimotion/usecases/release_template_doi.rb +42 -17
- data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
- data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
- data/lib/labimotion/utils/prop.rb +1 -0
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +11 -0
- 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
|
|
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
|
-
|
|
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.
|
|
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
|