tina4ruby 3.13.103 → 3.13.104

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6097dca4375329be7c8539cf9e40a8cde39fd9a6041169e58b9edf6bcbcf491a
4
- data.tar.gz: 9d90b71f00d6a59a151a5a3108319aed79faccaac0858f40c3572825765f0acf
3
+ metadata.gz: b2e628488f2b0de1d7ec0d271cc07562ff66aa22c198adfea5fb2d9f61581987
4
+ data.tar.gz: 1416a35dac038e0a20befbec32f68d5e2cdd4d160ec440c1c18d40c38a3b2034
5
5
  SHA512:
6
- metadata.gz: 74d5064e7aca1cffcdfd0389b6662a4f2d921ed18de43f8d9f0741bffa6a8ceb7d381af281b44b9e114237d7c783068c1adef1b709eb8c36efa017aa6eeb633e
7
- data.tar.gz: 13fd4fa1ea1e667eee942d516cd48c14a2488e686ace7d9597d3affe368d2e7ad8e86772367b8d5ed5b3cc94b9a5631a41d62694abf055999ccbab30c9efad99
6
+ metadata.gz: 118eb151f7ceab9069f13f2e147a81c9e24468fb97d850acddb0054b15acd6e922369c59a83127a09e09605cf8460b0cc606268911325e63651bbda64c315b36
7
+ data.tar.gz: 6045f620f3e0b1a6f489cd9215b1c8d52dba5ea3eb4f6db15ee768963a763ac6deb51f84e1809ea10de660cd89ad035a8cca06779f3373796ebf455b324631a2
@@ -108,6 +108,11 @@ module Tina4
108
108
  register_field(name, :json, nullable: nullable, default: default)
109
109
  end
110
110
 
111
+ def point_field(name, srid: Tina4::Point::DEFAULT_SRID, spatial_index: true, nullable: true, default: nil)
112
+ register_field(name, :point, srid: Integer(srid), spatial_index: spatial_index,
113
+ nullable: nullable, default: default)
114
+ end
115
+
111
116
  # Declare a foreign key integer column and auto-wire relationships.
112
117
  #
113
118
  # Automatically:
@@ -230,6 +235,13 @@ module Tina4
230
235
  # __setattr__ + object.__setattr__ default-seeding.
231
236
  attr_reader name
232
237
  define_method("#{name}=") do |value|
238
+ if type == :point && !value.nil?
239
+ point = Tina4::Point.parse(value, srid: options[:srid])
240
+ if point.srid != options[:srid]
241
+ raise ArgumentError, "Point field '#{name}' expects SRID #{options[:srid]}; received #{point.srid}"
242
+ end
243
+ value = point
244
+ end
233
245
  fields = (@assigned_fields ||= [])
234
246
  fields << name unless fields.include?(name)
235
247
  instance_variable_set("@#{name}", value)
data/lib/tina4/orm.rb CHANGED
@@ -245,7 +245,7 @@ module Tina4
245
245
  #
246
246
  # @return [Tina4::QueryBuilder]
247
247
  def query
248
- QueryBuilder.from_table(table_name, db: db)
248
+ QueryBuilder.from_table(table_name, db: db, primary_key: get_db_column(primary_key_field || :id))
249
249
  end
250
250
 
251
251
  def find(id_or_filter = nil, filter = nil, **kwargs)
@@ -529,7 +529,7 @@ module Tina4
529
529
  end
530
530
 
531
531
  def create_table
532
- return true if db.table_exists?(table_name)
532
+ return create_spatial_indexes if db.table_exists?(table_name)
533
533
 
534
534
  # v3.13.16: engine-aware DDL. Ruby used to emit SQLite-only DDL on
535
535
  # every driver — INTEGER for booleans, DATETIME for datetimes, and a
@@ -594,12 +594,17 @@ module Tina4
594
594
  datetime: datetime_sql,
595
595
  timestamp: "TIMESTAMP",
596
596
  blob: "BLOB",
597
- json: json_sql
597
+ json: json_sql,
598
+ point: nil
598
599
  }
599
600
 
600
601
  col_defs = []
601
602
  field_definitions.each do |name, opts|
602
- sql_type = type_map[opts[:type]] || "TEXT"
603
+ sql_type = if opts[:type] == :point
604
+ SQLTranslator.point_column_type(engine, opts[:srid] || Point::DEFAULT_SRID)
605
+ else
606
+ type_map[opts[:type]] || "TEXT"
607
+ end
603
608
  if opts[:type] == :string && opts[:length]
604
609
  sql_type = "VARCHAR(#{opts[:length]})"
605
610
  elsif opts[:type] == :decimal
@@ -687,13 +692,35 @@ module Tina4
687
692
  # ONE case is benign - re-raise anything else.
688
693
  raise ce unless ce.message.to_s =~ /no corresponding begin/i
689
694
  end
690
- true
695
+ create_spatial_indexes
696
+ rescue SpatialNotSupportedError
697
+ raise
691
698
  rescue => e
692
699
  Tina4::Log.error("create_table failed for #{table_name}: #{db.get_error || e.message}", { sql: sql })
693
700
  false
694
701
  end
695
702
  end
696
703
 
704
+ def create_spatial_indexes
705
+ fields = field_definitions.select { |_name, opts| opts[:type] == :point }
706
+ return true if fields.empty?
707
+ engine = db.get_database_type.to_s.downcase
708
+ begin
709
+ fields.each do |name, opts|
710
+ SQLTranslator.point_column_type(engine, opts[:srid] || Point::DEFAULT_SRID)
711
+ next unless opts.fetch(:spatial_index, true)
712
+ db.execute(SQLTranslator.spatial_index(engine, table_name, get_db_column(name)))
713
+ end
714
+ db.commit
715
+ true
716
+ rescue SpatialNotSupportedError
717
+ raise
718
+ rescue => e
719
+ Tina4::Log.error("spatial index creation failed for #{table_name}: #{e.message}")
720
+ false
721
+ end
722
+ end
723
+
697
724
  def scope(name, filter_sql, params = [])
698
725
  define_singleton_method(name) do |limit: 100, offset: 0|
699
726
  where(filter_sql, params, limit: limit, offset: offset)
@@ -721,6 +748,9 @@ module Tina4
721
748
  # leave the raw string in place
722
749
  end
723
750
  end
751
+ if fdef && fdef[:type] == :point && !value.nil?
752
+ value = Point.parse(value, srid: fdef[:srid] || Point::DEFAULT_SRID)
753
+ end
724
754
  setter = "#{attr_name}="
725
755
  instance.__send__(setter, value) if instance.respond_to?(setter)
726
756
  end
@@ -857,6 +887,12 @@ module Tina4
857
887
  # a.meta must not leak into b.meta). Parity with the Python master,
858
888
  # which deepcopies a JSONField's dict/list default per instance.
859
889
  d = Marshal.load(Marshal.dump(d)) if d.is_a?(Hash) || d.is_a?(Array)
890
+ if opts[:type] == :point && !d.nil?
891
+ d = Point.parse(d, srid: opts[:srid] || Point::DEFAULT_SRID)
892
+ if d.srid != (opts[:srid] || Point::DEFAULT_SRID)
893
+ raise ArgumentError, "Point field '#{name}' expects SRID #{opts[:srid] || Point::DEFAULT_SRID}; received #{d.srid}"
894
+ end
895
+ end
860
896
  # #165: seed the default straight into the ivar, BYPASSING the
861
897
  # tracking setter, so a default is not recorded as a caller
862
898
  # assignment (mirrors the Python master's object.__setattr__).
@@ -1283,7 +1319,8 @@ module Tina4
1283
1319
  key_case = binding.local_variable_get(:case) # :case is a reserved word
1284
1320
  hash = {}
1285
1321
  self.class.field_definitions.each_key do |name|
1286
- hash[name] = __send__(name)
1322
+ value = __send__(name)
1323
+ hash[name] = value.is_a?(Point) ? value.geojson : value
1287
1324
  end
1288
1325
 
1289
1326
  if include
@@ -1321,6 +1358,19 @@ module Tina4
1321
1358
  hash
1322
1359
  end
1323
1360
 
1361
+ def to_feature(geometry_field: nil, include: nil)
1362
+ point_fields = self.class.field_definitions.select { |_name, opts| opts[:type] == :point }.keys
1363
+ geometry_field = (geometry_field || point_fields.first)&.to_sym
1364
+ raise ArgumentError, "to_feature needs a declared point_field" unless geometry_field && point_fields.include?(geometry_field)
1365
+ properties = to_h(include: include)
1366
+ geometry = properties.delete(geometry_field)
1367
+ { type: "Feature", geometry: geometry, properties: properties }
1368
+ end
1369
+
1370
+ def self.feature_collection(models, geometry_field: nil, include: nil)
1371
+ { type: "FeatureCollection", features: models.map { |model| model.to_feature(geometry_field: geometry_field, include: include) } }
1372
+ end
1373
+
1324
1374
  alias to_hash to_h
1325
1375
  alias to_dict to_h
1326
1376
  alias to_object to_h
@@ -1377,6 +1427,7 @@ module Tina4
1377
1427
  if opts[:type] == :json && !value.nil? && !value.is_a?(String)
1378
1428
  value = JSON.generate(value)
1379
1429
  end
1430
+ value = value.ewkt if opts[:type] == :point && value.is_a?(Point)
1380
1431
  db_col = mapping[name.to_s] || name
1381
1432
  hash[db_col.to_sym] = value
1382
1433
  end
@@ -1400,6 +1451,7 @@ module Tina4
1400
1451
  if opts[:type] == :json && !value.nil? && !value.is_a?(String)
1401
1452
  value = JSON.generate(value)
1402
1453
  end
1454
+ value = value.ewkt if opts[:type] == :point && value.is_a?(Point)
1403
1455
  db_col = mapping[name.to_s] || name
1404
1456
  hash[db_col.to_sym] = value
1405
1457
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Tina4
6
+ class SpatialNotSupportedError < StandardError; end
7
+
8
+ # Immutable SRID-aware longitude/latitude point (ADR-0057).
9
+ class Point
10
+ DEFAULT_SRID = 4326
11
+ EWKB_SRID = 0x20000000
12
+ EWKB_M = 0x40000000
13
+ EWKB_Z = 0x80000000
14
+
15
+ attr_reader :lon, :lat, :srid
16
+
17
+ def initialize(lon, lat, srid: DEFAULT_SRID)
18
+ raise ArgumentError, "Point longitude and latitude must be numbers" if lon == true || lon == false || lat == true || lat == false
19
+ @lon = Float(lon)
20
+ @lat = Float(lat)
21
+ @srid = Integer(srid)
22
+ raise ArgumentError, "Point longitude and latitude must be finite" unless @lon.finite? && @lat.finite?
23
+ if @srid == DEFAULT_SRID
24
+ raise ArgumentError, "Point longitude #{@lon} is outside -180..180; Tina4 uses longitude, latitude order" unless (-180.0..180.0).cover?(@lon)
25
+ raise ArgumentError, "Point latitude #{@lat} is outside -90..90; Tina4 uses longitude, latitude order" unless (-90.0..90.0).cover?(@lat)
26
+ end
27
+ freeze
28
+ rescue TypeError, ArgumentError => e
29
+ raise e if e.message.start_with?("Point ")
30
+ raise ArgumentError, "Point longitude, latitude and SRID must be numeric"
31
+ end
32
+
33
+ def wkt = "POINT(#{format_coordinate(@lon)} #{format_coordinate(@lat)})"
34
+ def ewkt = "SRID=#{@srid};#{wkt}"
35
+ def geojson = { type: "Point", coordinates: [@lon, @lat] }
36
+ def to_h = geojson
37
+ def to_a = [@lon, @lat]
38
+ def to_json(*args) = geojson.to_json(*args)
39
+
40
+ def self.parse(value, srid: DEFAULT_SRID)
41
+ return value if value.is_a?(Point)
42
+ if value.is_a?(Array)
43
+ raise ArgumentError, "Point coordinate pair needs longitude and latitude" if value.length < 2
44
+ return new(value[0], value[1], srid: srid)
45
+ end
46
+ return from_geojson(value, srid) if value.is_a?(Hash)
47
+ if value.is_a?(String)
48
+ text = value.strip
49
+ match = text.match(/\A(?:SRID\s*=\s*(\d+)\s*;\s*)?POINT\s*(?:Z|M|ZM)?\s*\(\s*([-+0-9.eE]+)\s+([-+0-9.eE]+)(?:\s+[-+0-9.eE]+)*\s*\)\z/i)
50
+ return new(match[2], match[3], srid: match[1] ? match[1].to_i : srid) if match
51
+ raw = [text].pack("H*") if text.length >= 42 && text.length.even? && text.match?(/\A[0-9a-f]+\z/i)
52
+ raw ||= value.b if [0, 1].include?(value.getbyte(0))
53
+ return from_wkb(raw, srid) if raw
54
+ end
55
+ raise ArgumentError, "Point must be Point, [longitude, latitude], WKT/EWKT, GeoJSON or WKB/EWKB"
56
+ end
57
+
58
+ def self.geometry_binding(value, srid: DEFAULT_SRID)
59
+ return [parse(value, srid: srid).ewkt, :ewkt] if value.is_a?(Point) || value.is_a?(Array)
60
+ if value.is_a?(Hash)
61
+ geometry = value.fetch(:type, value["type"]).to_s.downcase == "feature" ? (value[:geometry] || value["geometry"]) : value
62
+ type = (geometry[:type] || geometry["type"]).to_s.downcase
63
+ allowed = %w[point linestring polygon multipoint multilinestring multipolygon geometrycollection]
64
+ raise ArgumentError, "GeoJSON geometry has an unsupported type" unless allowed.include?(type)
65
+ return [JSON.generate(geometry), :geojson]
66
+ end
67
+ if value.is_a?(String) && value.match?(/\A\s*(?:SRID\s*=\s*\d+\s*;\s*)?(?:POINT|LINESTRING|POLYGON|MULTIPOINT|MULTILINESTRING|MULTIPOLYGON|GEOMETRYCOLLECTION)\b/i)
68
+ return [value.match?(/\A\s*SRID/i) ? value.strip : "SRID=#{srid};#{value.strip}", :ewkt]
69
+ end
70
+ raise ArgumentError, "Geometry must be Point, coordinate pair, WKT/EWKT or GeoJSON"
71
+ end
72
+
73
+ def self.from_geojson(data, srid)
74
+ type = (data[:type] || data["type"]).to_s.downcase
75
+ geometry = type == "feature" ? (data[:geometry] || data["geometry"] || {}) : data
76
+ raise ArgumentError, "Point GeoJSON type must be Point" unless (geometry[:type] || geometry["type"]).to_s.downcase == "point"
77
+ coordinates = geometry[:coordinates] || geometry["coordinates"]
78
+ raise ArgumentError, "Point GeoJSON coordinates must be [longitude, latitude]" unless coordinates.is_a?(Array) && coordinates.length >= 2
79
+ new(coordinates[0], coordinates[1], srid: srid)
80
+ end
81
+ private_class_method :from_geojson
82
+
83
+ def self.from_wkb(raw, srid)
84
+ raise ArgumentError, "Point WKB is too short" if raw.bytesize < 21
85
+ little = raw.getbyte(0) == 1
86
+ type_word = raw.byteslice(1, 4).unpack1(little ? "V" : "N")
87
+ offset = 5
88
+ if (type_word & EWKB_SRID) != 0
89
+ srid = raw.byteslice(5, 4).unpack1(little ? "V" : "N")
90
+ offset = 9
91
+ end
92
+ code = (type_word & ~(EWKB_SRID | EWKB_Z | EWKB_M)) % 1000
93
+ raise ArgumentError, "WKB geometry is not a Point" unless code == 1 && raw.bytesize >= offset + 16
94
+ lon, lat = raw.byteslice(offset, 16).unpack(little ? "E2" : "G2")
95
+ new(lon, lat, srid: srid)
96
+ end
97
+ private_class_method :from_wkb
98
+
99
+ private
100
+
101
+ def format_coordinate(value)
102
+ text = format("%.15g", value)
103
+ text == "-0" ? "0" : text
104
+ end
105
+ end
106
+ end
@@ -19,10 +19,11 @@ module Tina4
19
19
  # .get
20
20
  #
21
21
  class QueryBuilder
22
- def initialize(table, db: nil)
22
+ def initialize(table, db: nil, primary_key: nil)
23
23
  @table = table
24
24
  @db = db
25
25
  @columns = ["*"]
26
+ @select_params = []
26
27
  @wheres = []
27
28
  @params = []
28
29
  @joins = []
@@ -30,6 +31,8 @@ module Tina4
30
31
  @havings = []
31
32
  @having_params = []
32
33
  @order_by_cols = []
34
+ @order_by_params = []
35
+ @primary_key = primary_key&.to_s
33
36
  @limit_val = nil
34
37
  @offset_val = nil
35
38
  end
@@ -39,8 +42,8 @@ module Tina4
39
42
  # @param table_name [String] The database table name.
40
43
  # @param db [Object, nil] Optional database connection.
41
44
  # @return [QueryBuilder]
42
- def self.from_table(table_name, db: nil)
43
- new(table_name, db: db)
45
+ def self.from_table(table_name, db: nil, primary_key: nil)
46
+ new(table_name, db: db, primary_key: primary_key)
44
47
  end
45
48
 
46
49
  # Set the columns to select.
@@ -48,7 +51,10 @@ module Tina4
48
51
  # @param columns [Array<String>] Column names.
49
52
  # @return [self]
50
53
  def select(*columns)
51
- @columns = columns unless columns.empty?
54
+ unless columns.empty?
55
+ @columns = columns
56
+ @select_params = []
57
+ end
52
58
  self
53
59
  end
54
60
 
@@ -123,6 +129,46 @@ module Tina4
123
129
  self
124
130
  end
125
131
 
132
+ def within_distance(column, point, radius_metres, srid: Point::DEFAULT_SRID)
133
+ radius = Float(radius_metres)
134
+ raise ArgumentError, "Spatial radius must be finite and greater than or equal to zero" unless radius.finite? && radius >= 0
135
+ point = Point.parse(point, srid: srid)
136
+ where(SQLTranslator.within_distance(engine, column, point.srid), [point.lon, point.lat, radius])
137
+ end
138
+
139
+ def intersects(column, geometry, srid: Point::DEFAULT_SRID)
140
+ bound, form = Point.geometry_binding(geometry, srid: srid)
141
+ where(SQLTranslator.intersects(engine, column, form, srid), [bound])
142
+ end
143
+
144
+ def bbox(column, min_lon, min_lat, max_lon, max_lat, srid: Point::DEFAULT_SRID)
145
+ values = [min_lon, min_lat, max_lon, max_lat].map { |value| Float(value) }
146
+ raise ArgumentError, "Bounding-box coordinates must be finite" unless values.all?(&:finite?)
147
+ west, south, east, north = values
148
+ Point.new(west, south, srid: srid)
149
+ Point.new(east, north, srid: srid)
150
+ raise ArgumentError, "Bounding box must be ordered west, south, east, north" if west > east || south > north
151
+ where(SQLTranslator.bbox(engine, column, srid), values)
152
+ end
153
+
154
+ def select_distance(column, point, alias_name: "distance", srid: Point::DEFAULT_SRID)
155
+ point = Point.parse(point, srid: srid)
156
+ @columns << SQLTranslator.distance_as(engine, column, alias_name, point.srid)
157
+ @select_params.concat([point.lon, point.lat])
158
+ self
159
+ end
160
+
161
+ def order_by_distance(column, point, direction: "ASC", srid: Point::DEFAULT_SRID)
162
+ direction = direction.to_s.upcase
163
+ raise ArgumentError, "Distance order direction must be ASC or DESC" unless %w[ASC DESC].include?(direction)
164
+ raise ArgumentError, "Stable spatial ordering needs a primary key; use ORM.query or pass primary_key:" if @primary_key.to_s.empty?
165
+ point = Point.parse(point, srid: srid)
166
+ @order_by_cols << "#{SQLTranslator.distance(engine, column, point.srid)} #{direction}"
167
+ @order_by_params.concat([point.lon, point.lat])
168
+ @order_by_cols << "#{SQLTranslator.spatial_identifier(@primary_key, 'primary key')} ASC"
169
+ self
170
+ end
171
+
126
172
  # Set LIMIT and optional OFFSET.
127
173
  #
128
174
  # @param count [Integer] Maximum rows to return.
@@ -167,7 +213,7 @@ module Tina4
167
213
  def get
168
214
  ensure_db!
169
215
  sql = to_sql
170
- all_params = @params + @having_params
216
+ all_params = @select_params + @params + @having_params + @order_by_params
171
217
 
172
218
  @db.fetch(
173
219
  sql,
@@ -183,7 +229,7 @@ module Tina4
183
229
  def first
184
230
  ensure_db!
185
231
  sql = to_sql
186
- all_params = @params + @having_params
232
+ all_params = @select_params + @params + @having_params + @order_by_params
187
233
 
188
234
  @db.fetch_one(sql, all_params.empty? ? [] : all_params)
189
235
  end
@@ -196,9 +242,18 @@ module Tina4
196
242
 
197
243
  # Build a count query by replacing columns
198
244
  original = @columns
245
+ original_select_params = @select_params
246
+ original_order = @order_by_cols
247
+ original_order_params = @order_by_params
199
248
  @columns = ["COUNT(*) as cnt"]
249
+ @select_params = []
250
+ @order_by_cols = []
251
+ @order_by_params = []
200
252
  sql = to_sql
201
253
  @columns = original
254
+ @select_params = original_select_params
255
+ @order_by_cols = original_order
256
+ @order_by_params = original_order_params
202
257
 
203
258
  all_params = @params + @having_params
204
259
 
@@ -272,6 +327,11 @@ module Tina4
272
327
 
273
328
  private
274
329
 
330
+ def engine
331
+ ensure_db!
332
+ @db.get_database_type
333
+ end
334
+
275
335
  # Parse a single SQL condition into a MongoDB filter hash.
276
336
  #
277
337
  # @return [Array(Hash, Integer)] [mongo_condition, updated_param_index]
@@ -1174,6 +1174,12 @@ module Tina4
1174
1174
  # the auth gate into a 500. It degrades to an empty session, which means
1175
1175
  # no token, which means the ordinary 401 below - a SERVED request.
1176
1176
  session = Tina4::Session.new(env, degrade_on_backend_failure: true)
1177
+ sso = session.get("_tina4_sso")
1178
+ identity = sso.is_a?(Hash) ? sso["identity"] : nil
1179
+ if identity.is_a?(Hash) && identity["issuer"] && identity["subject"]
1180
+ env["tina4.auth_payload"] = identity
1181
+ return nil
1182
+ end
1177
1183
  session_token = session.get("token")
1178
1184
  if session_token && !session_token.empty?
1179
1185
  token = session_token
data/lib/tina4/session.rb CHANGED
@@ -237,7 +237,7 @@ module Tina4
237
237
 
238
238
  # Return all session data
239
239
  def all
240
- @data.dup
240
+ @data.reject { |key, _| %w[_tina4_sso _tina4_sso_pending].include?(key.to_s) }
241
241
  end
242
242
 
243
243
  # Flash data: set a value that is removed after next read.
@@ -16,7 +16,70 @@ module Tina4
16
16
  # # => "SELECT * FROM users ROWS 6 TO 15"
17
17
  #
18
18
  class SQLTranslator
19
+ SPATIAL_ENGINES = %w[postgres postgresql].freeze
20
+ SPATIAL_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*\z/
21
+
19
22
  class << self
23
+ def require_spatial(engine, feature)
24
+ name = engine.to_s.downcase
25
+ return name if SPATIAL_ENGINES.include?(name)
26
+ raise SpatialNotSupportedError,
27
+ "#{feature} is not supported on the '#{name.empty? ? 'unknown' : name}' database engine. " \
28
+ "Tina4 GIS support is PostGIS-first: use PostgreSQL with CREATE EXTENSION postgis. " \
29
+ "Tina4 will not replace a spatial query with an approximate coordinate query."
30
+ end
31
+
32
+ def spatial_identifier(name, what = "column")
33
+ text = name.to_s
34
+ raise ArgumentError, "Spatial #{what} is not a valid SQL identifier: #{text}" unless SPATIAL_IDENTIFIER.match?(text)
35
+ text
36
+ end
37
+
38
+ def point_column_type(engine, srid = Point::DEFAULT_SRID)
39
+ require_spatial(engine, "PointField")
40
+ "geography(Point,#{Integer(srid)})"
41
+ end
42
+
43
+ def spatial_index(engine, table, column)
44
+ require_spatial(engine, "spatial index creation")
45
+ table = spatial_identifier(table, "table")
46
+ column = spatial_identifier(column)
47
+ "CREATE INDEX IF NOT EXISTS #{table.tr('.', '_')}_#{column}_gist ON #{table} USING GIST (#{column})"
48
+ end
49
+
50
+ def point_literal(engine, srid = Point::DEFAULT_SRID)
51
+ require_spatial(engine, "spatial predicates")
52
+ "ST_SetSRID(ST_MakePoint(?, ?), #{Integer(srid)})::geography"
53
+ end
54
+
55
+ def within_distance(engine, column, srid = Point::DEFAULT_SRID)
56
+ "ST_DWithin(#{spatial_identifier(column)}, #{point_literal(engine, srid)}, ?)"
57
+ end
58
+
59
+ def distance(engine, column, srid = Point::DEFAULT_SRID)
60
+ "ST_Distance(#{spatial_identifier(column)}, #{point_literal(engine, srid)})"
61
+ end
62
+
63
+ def distance_as(engine, column, alias_name, srid = Point::DEFAULT_SRID)
64
+ "#{distance(engine, column, srid)} AS #{spatial_identifier(alias_name, 'result alias')}"
65
+ end
66
+
67
+ def geometry_literal(engine, form = :ewkt, srid = Point::DEFAULT_SRID)
68
+ require_spatial(engine, "spatial predicates")
69
+ return "ST_GeogFromText(?)" if form.to_sym == :ewkt
70
+ return "ST_SetSRID(ST_GeomFromGeoJSON(?), #{Integer(srid)})::geography" if form.to_sym == :geojson
71
+ raise ArgumentError, "Unsupported spatial geometry form: #{form}"
72
+ end
73
+
74
+ def intersects(engine, column, form = :ewkt, srid = Point::DEFAULT_SRID)
75
+ "ST_Intersects(#{spatial_identifier(column)}, #{geometry_literal(engine, form, srid)})"
76
+ end
77
+
78
+ def bbox(engine, column, srid = Point::DEFAULT_SRID)
79
+ require_spatial(engine, "bbox")
80
+ "ST_Intersects(#{spatial_identifier(column)}, ST_MakeEnvelope(?, ?, ?, ?, #{Integer(srid)})::geography)"
81
+ end
82
+
20
83
  # ── Literal-safe rewriting ────────────────────────────────────
21
84
  #
22
85
  # A dialect rewrite (|| -> CONCAT, TRUE -> 1, ILIKE -> LOWER LIKE) must NEVER
data/lib/tina4/sso.rb ADDED
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest"
5
+ require "json"
6
+ require "net/http"
7
+ require "openssl"
8
+ require "securerandom"
9
+ require "uri"
10
+
11
+ module Tina4
12
+ class SsoError < StandardError; end
13
+
14
+ # Provider-neutral, configuration-first OpenID Connect SSO.
15
+ class Sso
16
+ PENDING_KEY = "_tina4_sso_pending"
17
+ SESSION_KEY = "_tina4_sso"
18
+ attr_reader :issuer, :client_id, :client_secret, :redirect_uri, :scopes,
19
+ :verify, :post_logout_redirect_uri, :claim_map
20
+ @mounted = false
21
+
22
+ def initialize(options = {})
23
+ @issuer = (options[:issuer] || ENV["TINA4_SSO_ISSUER"] || "").sub(%r{/$}, "")
24
+ @client_id = options[:client_id] || ENV["TINA4_SSO_CLIENT_ID"] || ""
25
+ @client_secret = options.key?(:client_secret) ? options[:client_secret] : ENV["TINA4_SSO_CLIENT_SECRET"]
26
+ @redirect_uri = options[:redirect_uri] || ENV["TINA4_SSO_REDIRECT_URI"] || ""
27
+ @scopes = options[:scopes] || json_env("TINA4_SSO_SCOPES", %w[openid profile email])
28
+ @verify = (options[:verify] || ENV["TINA4_SSO_VERIFY"] || "introspection").downcase
29
+ @post_logout_redirect_uri = options[:post_logout_redirect_uri] || ENV["TINA4_SSO_POST_LOGOUT_REDIRECT_URI"]
30
+ @claim_map = options[:claim_map] || json_env("TINA4_SSO_CLAIM_MAP", {})
31
+ @metadata = {}
32
+ validate_config!
33
+ end
34
+
35
+ def self.from_issuer(options = {})
36
+ new(options).tap(&:discover)
37
+ end
38
+
39
+ def self.configured?
40
+ %w[TINA4_SSO_ISSUER TINA4_SSO_CLIENT_ID TINA4_SSO_REDIRECT_URI].all? { |key| !ENV[key].to_s.empty? }
41
+ end
42
+
43
+ def json_env(name, fallback)
44
+ raw = ENV[name]
45
+ raw.nil? || raw.empty? ? fallback : JSON.parse(raw)
46
+ rescue JSON::ParserError => e
47
+ raise SsoError, "#{name} must be valid JSON", cause: e
48
+ end
49
+
50
+ def self.secure_url!(value, name)
51
+ uri = URI.parse(value)
52
+ raise SsoError, "#{name} must be an absolute URL" unless uri.absolute? && uri.host
53
+
54
+ loopback = %w[localhost 127.0.0.1 ::1].include?(uri.host)
55
+ raise SsoError, "#{name} must use HTTPS except on loopback" unless uri.scheme == "https" || (uri.scheme == "http" && loopback)
56
+ rescue URI::InvalidURIError => e
57
+ raise SsoError, "#{name} must be an absolute URL", cause: e
58
+ end
59
+
60
+ def validate_config!
61
+ if @issuer.empty? || @client_id.empty? || @redirect_uri.empty?
62
+ raise SsoError, "TINA4_SSO_ISSUER, TINA4_SSO_CLIENT_ID and TINA4_SSO_REDIRECT_URI are required"
63
+ end
64
+ self.class.secure_url!(@issuer, "issuer")
65
+ self.class.secure_url!(@redirect_uri, "redirect URI")
66
+ raise SsoError, "TINA4_SSO_VERIFY must be introspection or jwks" unless %w[introspection jwks].include?(@verify)
67
+ raise SsoError, "jwks verification requires an installed cryptography capability" if @verify == "jwks"
68
+ raise SsoError, "introspection verification requires TINA4_SSO_CLIENT_SECRET" if @verify == "introspection" && @client_secret.to_s.empty?
69
+ raise SsoError, "TINA4_SSO_SCOPES must be a list containing openid" unless @scopes.is_a?(Array) && @scopes.include?("openid")
70
+ end
71
+
72
+ def request_json(url, form: nil, bearer: nil, basic: false)
73
+ uri = URI.parse(url)
74
+ request = form ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
75
+ request["Accept"] = "application/json"
76
+ request.set_form_data(form.transform_values(&:to_s)) if form
77
+ request["Authorization"] = "Bearer #{bearer}" if bearer
78
+ request.basic_auth(@client_id, @client_secret) if basic
79
+ http = Net::HTTP.new(uri.host, uri.port)
80
+ http.use_ssl = uri.scheme == "https"
81
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
82
+ http.open_timeout = 10
83
+ http.read_timeout = 10
84
+ response = http.request(request)
85
+ raise SsoError, "OIDC provider request failed" unless response.is_a?(Net::HTTPSuccess)
86
+ result = JSON.parse(response.body)
87
+ raise SsoError, "OIDC provider returned a non-object response" unless result.is_a?(Hash)
88
+ result
89
+ rescue JSON::ParserError, IOError, SystemCallError, Timeout::Error => e
90
+ raise SsoError, "OIDC provider request failed", cause: e
91
+ end
92
+
93
+ def discover(force = false)
94
+ return @metadata.dup unless @metadata.empty? || force
95
+ result = request_json("#{@issuer}/.well-known/openid-configuration")
96
+ raise SsoError, "OIDC discovery issuer does not exactly match configuration" unless result["issuer"] == @issuer
97
+ required = %w[authorization_endpoint token_endpoint]
98
+ required << "introspection_endpoint" if @verify == "introspection"
99
+ required.each do |key|
100
+ raise SsoError, "OIDC discovery is missing #{key}" if result[key].to_s.empty?
101
+ self.class.secure_url!(result[key], key)
102
+ end
103
+ @metadata = result
104
+ result.dup
105
+ end
106
+
107
+ def self.safe_return(value)
108
+ return "/" if value.to_s.empty? || !value.start_with?("/") || value.start_with?("//") || value.include?("\\")
109
+ value.each_byte.any? { |byte| byte < 32 } ? "/" : value
110
+ end
111
+
112
+ def session(value)
113
+ value.is_a?(Tina4::Session) ? value : value&.session
114
+ end
115
+
116
+ def login(request_or_session, return_to = "/")
117
+ current = session(request_or_session)
118
+ raise SsoError, "SSO login requires a Tina4 Session" unless current
119
+ state = SecureRandom.urlsafe_base64(32)
120
+ nonce = SecureRandom.urlsafe_base64(32)
121
+ verifier = SecureRandom.urlsafe_base64(64)
122
+ challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
123
+ current.set(PENDING_KEY, {
124
+ "state" => state, "nonce" => nonce, "verifier" => verifier,
125
+ "return_to" => self.class.safe_return(return_to), "created_at" => Time.now.to_i
126
+ })
127
+ query = URI.encode_www_form(
128
+ client_id: @client_id, redirect_uri: @redirect_uri, response_type: "code",
129
+ scope: @scopes.join(" "), state: state, nonce: nonce,
130
+ code_challenge: challenge, code_challenge_method: "S256"
131
+ )
132
+ "#{discover['authorization_endpoint']}?#{query}"
133
+ end
134
+
135
+ def self.secure_equal(left, right)
136
+ a = left.to_s
137
+ b = right.to_s
138
+ a.bytesize == b.bytesize && OpenSSL.fixed_length_secure_compare(a, b)
139
+ end
140
+
141
+ def self.jwt_payload(token)
142
+ part = token.split(".")[1].to_s
143
+ JSON.parse(Base64.urlsafe_decode64(part.ljust((part.length + 3) / 4 * 4, "=")))
144
+ rescue JSON::ParserError, ArgumentError
145
+ raise SsoError, "provider returned an invalid ID token"
146
+ end
147
+
148
+ def introspect(access_token)
149
+ result = request_json(discover["introspection_endpoint"],
150
+ form: { token: access_token, token_type_hint: "access_token" }, basic: true)
151
+ unless result["active"] == true && result["iss"] == @issuer
152
+ raise SsoError, "OIDC access token is inactive or has the wrong issuer"
153
+ end
154
+ audience = result["aud"] || result["client_id"]
155
+ valid = audience.is_a?(Array) ? audience.include?(@client_id) : audience == @client_id
156
+ valid ||= result["client_id"] == @client_id
157
+ raise SsoError, "OIDC token audience mismatch" unless valid
158
+ result
159
+ end
160
+
161
+ def claim(claims, configured, fallback)
162
+ (configured || fallback).split(".").reduce(claims) { |value, key| value.is_a?(Hash) ? value[key] : nil }
163
+ end
164
+
165
+ def normalize(claims)
166
+ subject = claim(claims, @claim_map["subject"], "sub")
167
+ identity_issuer = claim(claims, @claim_map["issuer"], "iss") || @issuer
168
+ raise SsoError, "OIDC identity is missing a valid issuer or subject" if subject.to_s.empty? || identity_issuer != @issuer
169
+ roles = Array(claim(claims, @claim_map["roles"], "realm_access.roles"))
170
+ roles += Array(claims.dig("resource_access", @client_id, "roles"))
171
+ groups = Array(claim(claims, @claim_map["groups"], "groups"))
172
+ {
173
+ "issuer" => identity_issuer, "subject" => subject,
174
+ "username" => claim(claims, @claim_map["username"], "preferred_username"),
175
+ "email" => claim(claims, @claim_map["email"], "email"),
176
+ "name" => claim(claims, @claim_map["name"], "name"),
177
+ "roles" => roles.map(&:to_s).uniq.sort, "groups" => groups.map(&:to_s).uniq.sort
178
+ }
179
+ end
180
+
181
+ def callback(request_or_session, query = nil)
182
+ current = session(request_or_session)
183
+ values = query || request_or_session.params
184
+ pending = current&.get(PENDING_KEY)
185
+ current&.delete(PENDING_KEY)
186
+ unless pending.is_a?(Hash) && !values["code"].to_s.empty? && self.class.secure_equal(values["state"], pending["state"])
187
+ raise SsoError, "OIDC callback state is invalid or already consumed"
188
+ end
189
+ raise SsoError, "OIDC callback state has expired" if Time.now.to_i - pending.fetch("created_at", 0).to_i > 600
190
+ metadata = discover
191
+ tokens = request_json(metadata["token_endpoint"], form: {
192
+ grant_type: "authorization_code", code: values["code"], redirect_uri: @redirect_uri,
193
+ client_id: @client_id, code_verifier: pending["verifier"]
194
+ }, basic: !@client_secret.to_s.empty?)
195
+ raise SsoError, "OIDC token response is incomplete" if tokens["access_token"].to_s.empty? || tokens["id_token"].to_s.empty?
196
+ raise SsoError, "JWKS verification requires an installed cryptography capability" if @verify == "jwks"
197
+ claims = introspect(tokens["access_token"])
198
+ unless self.class.secure_equal(self.class.jwt_payload(tokens["id_token"])["nonce"], pending["nonce"])
199
+ raise SsoError, "OIDC ID token nonce mismatch"
200
+ end
201
+ claims.merge!(request_json(metadata["userinfo_endpoint"], bearer: tokens["access_token"])) if metadata["userinfo_endpoint"]
202
+ identity = normalize(claims)
203
+ current.regenerate
204
+ current.set(SESSION_KEY, {
205
+ "version" => 1, "identity" => identity, "access_token" => tokens["access_token"],
206
+ "refresh_token" => tokens["refresh_token"], "id_token" => tokens["id_token"],
207
+ "expires_at" => Time.now.to_i + tokens.fetch("expires_in", 0).to_i
208
+ })
209
+ { "identity" => identity, "return_to" => self.class.safe_return(pending["return_to"]) }
210
+ end
211
+
212
+ def identity(request_or_session)
213
+ stored = session(request_or_session)&.get(SESSION_KEY)
214
+ value = stored.is_a?(Hash) ? stored["identity"] : nil
215
+ request_or_session.user = value if value && !request_or_session.is_a?(Tina4::Session)
216
+ value
217
+ end
218
+
219
+ def refresh(request_or_session)
220
+ current = session(request_or_session)
221
+ stored = current&.get(SESSION_KEY)
222
+ unless stored.is_a?(Hash) && !stored["refresh_token"].to_s.empty?
223
+ current&.delete(SESSION_KEY)
224
+ raise SsoError, "OIDC session cannot be refreshed"
225
+ end
226
+ metadata = discover
227
+ tokens = request_json(metadata["token_endpoint"], form: {
228
+ grant_type: "refresh_token", refresh_token: stored["refresh_token"], client_id: @client_id
229
+ }, basic: !@client_secret.to_s.empty?)
230
+ claims = introspect(tokens["access_token"])
231
+ claims.merge!(request_json(metadata["userinfo_endpoint"], bearer: tokens["access_token"])) if metadata["userinfo_endpoint"]
232
+ value = normalize(claims)
233
+ current.set(SESSION_KEY, stored.merge(
234
+ "identity" => value, "access_token" => tokens["access_token"],
235
+ "refresh_token" => tokens["refresh_token"] || stored["refresh_token"],
236
+ "id_token" => tokens["id_token"] || stored["id_token"],
237
+ "expires_at" => Time.now.to_i + tokens.fetch("expires_in", 0).to_i
238
+ ))
239
+ value
240
+ rescue StandardError
241
+ current&.delete(SESSION_KEY)
242
+ raise
243
+ end
244
+
245
+ def logout(request_or_session, return_to = "/")
246
+ current = session(request_or_session)
247
+ stored = current&.get(SESSION_KEY)
248
+ current&.destroy
249
+ endpoint = discover["end_session_endpoint"]
250
+ target = @post_logout_redirect_uri || self.class.safe_return(return_to)
251
+ return target unless endpoint
252
+ params = { post_logout_redirect_uri: target, client_id: @client_id }
253
+ params[:id_token_hint] = stored["id_token"] if stored.is_a?(Hash) && stored["id_token"]
254
+ "#{endpoint}?#{URI.encode_www_form(params)}"
255
+ end
256
+
257
+ def self.mount_configured
258
+ return false if @mounted || !configured?
259
+ owned = [["GET", "/auth/login"], ["GET", "/auth/callback"], ["POST", "/auth/logout"]]
260
+ collisions = Tina4::Router.routes.select { |route| owned.include?([route.method, route.path]) }
261
+ unless collisions.empty?
262
+ raise SsoError, "SSO route collision: #{collisions.map { |route| "#{route.method} #{route.path}" }.join(', ')}"
263
+ end
264
+ sso = from_issuer
265
+ Tina4::Router.get("/auth/login") do |request, response|
266
+ response.redirect(sso.login(request, request.params["return_to"] || "/"))
267
+ end
268
+ Tina4::Router.get("/auth/callback") do |request, response|
269
+ begin
270
+ response.redirect(sso.callback(request)["return_to"])
271
+ rescue SsoError => e
272
+ response.json({ error: "SSO_CALLBACK_FAILED", message: e.message }, 400)
273
+ end
274
+ end
275
+ Tina4::Router.post("/auth/logout") do |request, response|
276
+ response.redirect(sso.logout(request, request.params["return_to"] || "/"))
277
+ end
278
+ @mounted = true
279
+ true
280
+ end
281
+ end
282
+
283
+ SSO = Sso
284
+ end
data/lib/tina4/swagger.rb CHANGED
@@ -161,6 +161,17 @@ module Tina4
161
161
  }
162
162
  end
163
163
 
164
+ sso_issuer = ENV["TINA4_SSO_ISSUER"].to_s.sub(%r{/$}, "")
165
+ unless sso_issuer.empty?
166
+ schemes["oidc"] = {
167
+ "type" => "openIdConnect",
168
+ "openIdConnectUrl" => "#{sso_issuer}/.well-known/openid-configuration"
169
+ }
170
+ schemes["ssoSession"] = {
171
+ "type" => "apiKey", "in" => "cookie", "name" => "tina4_session"
172
+ }
173
+ end
174
+
164
175
  # Registered schemes win (let an app override bearerAuth or add oauth2).
165
176
  @registered_schemes.each { |name, defn| schemes[name] = defn }
166
177
  schemes
@@ -302,7 +313,11 @@ module Tina4
302
313
  #
303
314
  # Both flags are honoured. A custom auth_handler protects a route even
304
315
  # when auth_required is false for its method, so it is still documented.
305
- return sanitize_security([{ default_scheme => [] }], schemes) if route.auth_required || route.auth_handler
316
+ if route.auth_required || route.auth_handler
317
+ requirements = [{ default_scheme => [] }]
318
+ requirements << { "ssoSession" => [] } if default_scheme == "bearerAuth" && schemes.key?("ssoSession")
319
+ return sanitize_security(requirements, schemes)
320
+ end
306
321
 
307
322
  nil
308
323
  end
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.103"
4
+ VERSION = "3.13.104"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -15,6 +15,7 @@ require_relative "tina4/database_adapter"
15
15
  require_relative "tina4/database_url"
16
16
  require_relative "tina4/database"
17
17
  require_relative "tina4/database_result"
18
+ require_relative "tina4/point"
18
19
  require_relative "tina4/field_types"
19
20
  require_relative "tina4/orm"
20
21
  require_relative "tina4/query_builder"
@@ -25,6 +26,7 @@ require_relative "tina4/template"
25
26
  require_relative "tina4/frond"
26
27
  require_relative "tina4/auth"
27
28
  require_relative "tina4/session"
29
+ require_relative "tina4/sso"
28
30
  require_relative "tina4/middleware"
29
31
  require_relative "tina4/cors"
30
32
  require_relative "tina4/rate_limiter"
@@ -487,6 +489,10 @@ module Tina4
487
489
  # Auto-discover routes
488
490
  auto_discover(root_dir)
489
491
 
492
+ # Configuration-first OIDC mounts after application discovery so a
493
+ # canonical-path collision fails loudly rather than being shadowed.
494
+ Tina4::Sso.mount_configured
495
+
490
496
  # Apply pending DB migrations on startup (non-breaking — see method doc).
491
497
  # Runs AFTER route discovery / DB bind, BEFORE serving.
492
498
  auto_migrate_on_startup!(root_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.103
4
+ version: 3.13.104
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-08-16 00:00:00.000000000 Z
11
+ date: 2026-08-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -397,6 +397,7 @@ files:
397
397
  - lib/tina4/mqtt_message.rb
398
398
  - lib/tina4/orm.rb
399
399
  - lib/tina4/plan.rb
400
+ - lib/tina4/point.rb
400
401
  - lib/tina4/port_takeover.rb
401
402
  - lib/tina4/project_index.rb
402
403
  - lib/tina4/public/__feedback/widget.js
@@ -448,6 +449,7 @@ files:
448
449
  - lib/tina4/session_handlers/valkey_handler.rb
449
450
  - lib/tina4/shutdown.rb
450
451
  - lib/tina4/sql_translator.rb
452
+ - lib/tina4/sso.rb
451
453
  - lib/tina4/swagger.rb
452
454
  - lib/tina4/template.rb
453
455
  - lib/tina4/templates/base.twig