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 +4 -4
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/orm.rb +58 -6
- data/lib/tina4/point.rb +106 -0
- data/lib/tina4/query_builder.rb +66 -6
- data/lib/tina4/rack_app.rb +6 -0
- data/lib/tina4/session.rb +1 -1
- data/lib/tina4/sql_translator.rb +63 -0
- data/lib/tina4/sso.rb +284 -0
- data/lib/tina4/swagger.rb +16 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +6 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b2e628488f2b0de1d7ec0d271cc07562ff66aa22c198adfea5fb2d9f61581987
|
|
4
|
+
data.tar.gz: 1416a35dac038e0a20befbec32f68d5e2cdd4d160ec440c1c18d40c38a3b2034
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 118eb151f7ceab9069f13f2e147a81c9e24468fb97d850acddb0054b15acd6e922369c59a83127a09e09605cf8460b0cc606268911325e63651bbda64c315b36
|
|
7
|
+
data.tar.gz: 6045f620f3e0b1a6f489cd9215b1c8d52dba5ea3eb4f6db15ee768963a763ac6deb51f84e1809ea10de660cd89ad035a8cca06779f3373796ebf455b324631a2
|
data/lib/tina4/field_types.rb
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/tina4/point.rb
ADDED
|
@@ -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
|
data/lib/tina4/query_builder.rb
CHANGED
|
@@ -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
|
-
|
|
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]
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -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
data/lib/tina4/sql_translator.rb
CHANGED
|
@@ -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
|
-
|
|
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
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.
|
|
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-
|
|
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
|