mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-07-02 17:37:37 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f86eb0f06 | |||
| 3dac55cb19 | |||
| bbb2c6d127 | |||
| 3fec7b1a90 | |||
| 910390f62d | |||
| b9bed67bd7 | |||
| 11ef16f75a | |||
| 48b72a7631 | |||
| 4c512acf25 | |||
| 07a402634e |
+43
-18
@@ -446,18 +446,36 @@ func containsTopLevelOR(clause string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitByAND splits a WHERE clause by AND operators (case-insensitive)
|
// splitByAND splits a WHERE clause by AND operators (case-insensitive).
|
||||||
// This is parenthesis-aware and won't split on AND operators inside subqueries
|
// It is parenthesis-aware (won't split inside subqueries), quote-aware
|
||||||
|
// (won't split on AND inside single-quoted strings), and BETWEEN-aware
|
||||||
|
// (won't split on the AND that separates the two operands of BETWEEN x AND y).
|
||||||
func splitByAND(where string) []string {
|
func splitByAND(where string) []string {
|
||||||
conditions := []string{}
|
conditions := []string{}
|
||||||
currentCondition := strings.Builder{}
|
currentCondition := strings.Builder{}
|
||||||
depth := 0 // Track parenthesis depth
|
depth := 0 // parenthesis nesting depth
|
||||||
|
inSingleQuote := false
|
||||||
|
afterBetween := false // true after seeing BETWEEN at depth 0; next AND belongs to it
|
||||||
i := 0
|
i := 0
|
||||||
|
|
||||||
for i < len(where) {
|
for i < len(where) {
|
||||||
ch := where[i]
|
ch := where[i]
|
||||||
|
|
||||||
// Track parenthesis depth
|
// Track single-quote state so we never split on AND inside string literals.
|
||||||
|
if ch == '\'' {
|
||||||
|
inSingleQuote = !inSingleQuote
|
||||||
|
currentCondition.WriteByte(ch)
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if inSingleQuote {
|
||||||
|
currentCondition.WriteByte(ch)
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track parenthesis depth (outside quotes only).
|
||||||
if ch == '(' {
|
if ch == '(' {
|
||||||
depth++
|
depth++
|
||||||
currentCondition.WriteByte(ch)
|
currentCondition.WriteByte(ch)
|
||||||
@@ -470,32 +488,39 @@ func splitByAND(where string) []string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only look for AND operators at depth 0 (not inside parentheses)
|
// All keyword checks only apply at depth 0 (not inside subqueries).
|
||||||
if depth == 0 {
|
if depth == 0 {
|
||||||
// Check if we're at an AND operator (case-insensitive)
|
// Detect " BETWEEN " (9 chars, case-insensitive) so the very next
|
||||||
// We need at least " AND " (5 chars) or " and " (5 chars)
|
// top-level AND is recognised as part of the BETWEEN syntax.
|
||||||
if i+5 <= len(where) {
|
if i+9 <= len(where) && strings.ToLower(where[i:i+9]) == " between " {
|
||||||
substring := where[i : i+5]
|
afterBetween = true
|
||||||
lowerSubstring := strings.ToLower(substring)
|
currentCondition.WriteString(where[i : i+9])
|
||||||
|
i += 9
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if lowerSubstring == " and " {
|
// Detect " AND " (5 chars, case-insensitive).
|
||||||
// Found an AND operator at the top level
|
if i+5 <= len(where) && strings.ToLower(where[i:i+5]) == " and " {
|
||||||
// Add the current condition to the list
|
if afterBetween {
|
||||||
conditions = append(conditions, currentCondition.String())
|
// This AND closes a BETWEEN expression — do NOT split.
|
||||||
currentCondition.Reset()
|
afterBetween = false
|
||||||
// Skip past the AND operator
|
currentCondition.WriteString(where[i : i+5])
|
||||||
i += 5
|
i += 5
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Regular conjunction — split here.
|
||||||
|
conditions = append(conditions, currentCondition.String())
|
||||||
|
currentCondition.Reset()
|
||||||
|
i += 5
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not an AND operator or we're inside parentheses, just add the character
|
|
||||||
currentCondition.WriteByte(ch)
|
currentCondition.WriteByte(ch)
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the last condition
|
// Add the last condition.
|
||||||
if currentCondition.Len() > 0 {
|
if currentCondition.Len() > 0 {
|
||||||
conditions = append(conditions, currentCondition.String())
|
conditions = append(conditions, currentCondition.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -520,6 +520,38 @@ func TestSplitByAND(t *testing.T) {
|
|||||||
input: "a = 1 AND b = 2 AND c = 3 and (select s from generate_series(1,10) s where s < 10 and s > 0 offset 2 limit 1) = 3",
|
input: "a = 1 AND b = 2 AND c = 3 and (select s from generate_series(1,10) s where s < 10 and s > 0 offset 2 limit 1) = 3",
|
||||||
expected: []string{"a = 1", "b = 2", "c = 3", "(select s from generate_series(1,10) s where s < 10 and s > 0 offset 2 limit 1) = 3"},
|
expected: []string{"a = 1", "b = 2", "c = 3", "(select s from generate_series(1,10) s where s < 10 and s > 0 offset 2 limit 1) = 3"},
|
||||||
},
|
},
|
||||||
|
// BETWEEN-aware cases: the AND inside BETWEEN x AND y must not cause a split.
|
||||||
|
{
|
||||||
|
name: "BETWEEN does not split on its AND",
|
||||||
|
input: "col between '2025-08-31' and '1970-01-01'",
|
||||||
|
expected: []string{"col between '2025-08-31' and '1970-01-01'"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BETWEEN uppercase AND",
|
||||||
|
input: "col BETWEEN '2025-08-31' AND '1970-01-01'",
|
||||||
|
expected: []string{"col BETWEEN '2025-08-31' AND '1970-01-01'"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "BETWEEN followed by a regular AND conjunction",
|
||||||
|
input: "col between 1 and 5 and other = 'x'",
|
||||||
|
expected: []string{"col between 1 and 5", "other = 'x'"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "two BETWEEN conditions joined by AND",
|
||||||
|
input: "col1 between 1 and 5 and col2 between 10 and 20",
|
||||||
|
expected: []string{"col1 between 1 and 5", "col2 between 10 and 20"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex OR block with multiple BETWEENs (real-world case)",
|
||||||
|
input: "tbl.applicationdate between '2025-08-31' and '1970-01-01'\n or tbl.capturedate between '2025-08-31' and '1970-01-01'\n or tbl.startdate between '2025-08-31' AND '1970-01-01'",
|
||||||
|
expected: []string{"tbl.applicationdate between '2025-08-31' and '1970-01-01'\n or tbl.capturedate between '2025-08-31' and '1970-01-01'\n or tbl.startdate between '2025-08-31' AND '1970-01-01'"},
|
||||||
|
},
|
||||||
|
// Quote-aware cases: AND inside a string literal must not split.
|
||||||
|
{
|
||||||
|
name: "AND inside single-quoted string is not a split point",
|
||||||
|
input: "comment = 'this and that' and status = 'active'",
|
||||||
|
expected: []string{"comment = 'this and that'", "status = 'active'"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -917,6 +949,25 @@ where: "(true AND status = 'active')",
|
|||||||
tableName: "unregistered_table",
|
tableName: "unregistered_table",
|
||||||
expected: "(true AND unregistered_table.status = 'active')",
|
expected: "(true AND unregistered_table.status = 'active')",
|
||||||
},
|
},
|
||||||
|
// BETWEEN regression: date literals inside BETWEEN must not be prefixed as columns.
|
||||||
|
{
|
||||||
|
name: "BETWEEN date range - second date must not be prefixed",
|
||||||
|
where: "applicationdate between '2025-08-31' and '1970-01-01'",
|
||||||
|
tableName: "unregistered_table",
|
||||||
|
expected: "unregistered_table.applicationdate between '2025-08-31' and '1970-01-01'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Already-prefixed BETWEEN column - unchanged",
|
||||||
|
where: `"v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01'`,
|
||||||
|
tableName: "v_webui_clients",
|
||||||
|
expected: `"v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01'`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Complex OR block with multiple BETWEENs - date values must not be prefixed",
|
||||||
|
where: `("v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01' or "v_webui_clients".clientcapturedate between '2025-08-31' and '1970-01-01' or "v_webui_clients".startdate between '2025-08-31' AND '1970-01-01')`,
|
||||||
|
tableName: "v_webui_clients",
|
||||||
|
expected: `("v_webui_clients".applicationdate between '2025-08-31' and '1970-01-01' or "v_webui_clients".clientcapturedate between '2025-08-31' and '1970-01-01' or "v_webui_clients".startdate between '2025-08-31' AND '1970-01-01')`,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ type ServerInstanceConfig struct {
|
|||||||
// GZIP enables GZIP compression middleware
|
// GZIP enables GZIP compression middleware
|
||||||
GZIP bool `mapstructure:"gzip"`
|
GZIP bool `mapstructure:"gzip"`
|
||||||
|
|
||||||
|
// HTTP2 enables HTTP/2 with the Extended CONNECT protocol (RFC 8441) for WebSocket support.
|
||||||
|
// Requires TLS; pair with SSLCert/SSLKey, SelfSignedSSL, or AutoTLS.
|
||||||
|
HTTP2 bool `mapstructure:"http2"`
|
||||||
|
|
||||||
// TLS/HTTPS configuration options (mutually exclusive)
|
// TLS/HTTPS configuration options (mutually exclusive)
|
||||||
// Option 1: Provide certificate and key files directly
|
// Option 1: Provide certificate and key files directly
|
||||||
SSLCert string `mapstructure:"ssl_cert"`
|
SSLCert string `mapstructure:"ssl_cert"`
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
"github.com/bitechdev/ResolveSpec/pkg/logger"
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/reflection"
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/restheadspec"
|
"github.com/bitechdev/ResolveSpec/pkg/restheadspec"
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/security"
|
"github.com/bitechdev/ResolveSpec/pkg/security"
|
||||||
)
|
)
|
||||||
@@ -367,13 +368,17 @@ func (h *Handler) SqlQueryList(sqlquery string, options SqlQueryOptions) HTTPFun
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "detail":
|
case "detail":
|
||||||
// Detail format: complex API with metadata
|
// Detail format: { count, fields, items, tablename, tableprefix, total }
|
||||||
|
tableName := r.URL.Path
|
||||||
|
tablePrefix := reflection.ExtractTableNameOnly(tableName)
|
||||||
|
fields := buildDetailFieldsFromRows(dbobjlist)
|
||||||
metaobj := map[string]interface{}{
|
metaobj := map[string]interface{}{
|
||||||
"items": dbobjlist,
|
|
||||||
"count": fmt.Sprintf("%d", len(dbobjlist)),
|
"count": fmt.Sprintf("%d", len(dbobjlist)),
|
||||||
|
"fields": fields,
|
||||||
|
"items": dbobjlist,
|
||||||
|
"tablename": tableName,
|
||||||
|
"tableprefix": tablePrefix,
|
||||||
"total": fmt.Sprintf("%d", total),
|
"total": fmt.Sprintf("%d", total),
|
||||||
"tablename": r.URL.Path,
|
|
||||||
"tableprefix": "gsql",
|
|
||||||
}
|
}
|
||||||
data, err := json.Marshal(metaobj)
|
data, err := json.Marshal(metaobj)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1079,6 +1084,49 @@ func getReplacementForBlankParam(sqlquery, param string) string {
|
|||||||
// return result
|
// return result
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
// buildDetailFieldsFromRows builds a field metadata list from the column names and value types
|
||||||
|
// of a raw SQL result set. Used when no model struct is available (funcspec raw queries).
|
||||||
|
func buildDetailFieldsFromRows(rows []map[string]interface{}) []reflection.ModelFieldDetail {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return []reflection.ModelFieldDetail{}
|
||||||
|
}
|
||||||
|
first := rows[0]
|
||||||
|
fields := make([]reflection.ModelFieldDetail, 0, len(first))
|
||||||
|
for colName, val := range first {
|
||||||
|
dataType := inferGoType(val)
|
||||||
|
fields = append(fields, reflection.ModelFieldDetail{
|
||||||
|
Name: colName,
|
||||||
|
DataType: dataType,
|
||||||
|
SQLName: colName,
|
||||||
|
SQLDataType: "",
|
||||||
|
SQLKey: "",
|
||||||
|
Nullable: val == nil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// inferGoType returns a simple type name for a value, used for detail field metadata.
|
||||||
|
func inferGoType(val interface{}) string {
|
||||||
|
if val == nil {
|
||||||
|
return "interface{}"
|
||||||
|
}
|
||||||
|
switch val.(type) {
|
||||||
|
case bool:
|
||||||
|
return "bool"
|
||||||
|
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||||
|
return "int64"
|
||||||
|
case float32, float64:
|
||||||
|
return "float64"
|
||||||
|
case string:
|
||||||
|
return "string"
|
||||||
|
case []byte:
|
||||||
|
return "[]byte"
|
||||||
|
default:
|
||||||
|
return "interface{}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// getIPAddress extracts the real IP address from the request
|
// getIPAddress extracts the real IP address from the request
|
||||||
func getIPAddress(r *http.Request) string {
|
func getIPAddress(r *http.Request) string {
|
||||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||||
|
|||||||
@@ -617,6 +617,91 @@ func TestSqlQueryList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "x-detailapi header returns detail format",
|
||||||
|
sqlQuery: "SELECT * FROM myschema.myentity",
|
||||||
|
noCount: false,
|
||||||
|
blankParams: false,
|
||||||
|
allowFilter: false,
|
||||||
|
headers: map[string]string{"x-detailapi": "true"},
|
||||||
|
setupDB: func() *MockDatabase {
|
||||||
|
return &MockDatabase{
|
||||||
|
RunInTransactionFunc: func(ctx context.Context, fn func(common.Database) error) error {
|
||||||
|
db := &MockDatabase{
|
||||||
|
QueryFunc: func(ctx context.Context, dest interface{}, query string, args ...interface{}) error {
|
||||||
|
if strings.Contains(query, "COUNT") {
|
||||||
|
dest.(*struct{ Count int64 }).Count = 3
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
*dest.(*[]map[string]interface{}) = []map[string]interface{}{
|
||||||
|
{"id": float64(1), "name": "Alice"},
|
||||||
|
{"id": float64(2), "name": "Bob"},
|
||||||
|
{"id": float64(3), "name": "Carol"},
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return fn(db)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
expectedStatus: 200,
|
||||||
|
validateResp: func(t *testing.T, w *httptest.ResponseRecorder) {
|
||||||
|
var resp map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("expected JSON object, got: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range []string{"count", "fields", "items", "tablename", "tableprefix", "total"} {
|
||||||
|
if _, ok := resp[key]; !ok {
|
||||||
|
t.Errorf("missing key %q in detail response", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var count, total string
|
||||||
|
json.Unmarshal(resp["count"], &count)
|
||||||
|
json.Unmarshal(resp["total"], &total)
|
||||||
|
if count != "3" {
|
||||||
|
t.Errorf("expected count %q, got %q", "3", count)
|
||||||
|
}
|
||||||
|
if total != "3" {
|
||||||
|
t.Errorf("expected total %q, got %q", "3", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []map[string]interface{}
|
||||||
|
if err := json.Unmarshal(resp["items"], &items); err != nil {
|
||||||
|
t.Fatalf("items is not an array: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 3 {
|
||||||
|
t.Errorf("expected 3 items, got %d", len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
var fields []map[string]interface{}
|
||||||
|
if err := json.Unmarshal(resp["fields"], &fields); err != nil {
|
||||||
|
t.Fatalf("fields is not an array: %v", err)
|
||||||
|
}
|
||||||
|
if len(fields) == 0 {
|
||||||
|
t.Error("expected non-empty fields list")
|
||||||
|
}
|
||||||
|
for _, f := range fields {
|
||||||
|
for _, key := range []string{"name", "datatype", "sqlname", "sqldatatype", "sqlkey", "nullable"} {
|
||||||
|
if _, ok := f[key]; !ok {
|
||||||
|
t.Errorf("field %v missing key %q", f, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tablename, tableprefix string
|
||||||
|
json.Unmarshal(resp["tablename"], &tablename)
|
||||||
|
json.Unmarshal(resp["tableprefix"], &tableprefix)
|
||||||
|
if tablename == "" {
|
||||||
|
t.Error("expected non-empty tablename")
|
||||||
|
}
|
||||||
|
if tableprefix == "" {
|
||||||
|
t.Error("expected non-empty tableprefix")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "List query with noCount",
|
name: "List query with noCount",
|
||||||
sqlQuery: "SELECT * FROM users",
|
sqlQuery: "SELECT * FROM users",
|
||||||
|
|||||||
@@ -2711,9 +2711,12 @@ func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.SetHeader("Content-Type", "application/json")
|
w.SetHeader("Content-Type", "application/json")
|
||||||
w.SetHeader("Content-Range", fmt.Sprintf("%d-%d/%d", metadata.Offset, int64(metadata.Offset)+metadata.Count, metadata.Filtered))
|
w.SetHeader("Content-Range", fmt.Sprintf("items %d-%d/%d", metadata.Offset, int64(metadata.Offset)+metadata.Count, metadata.Filtered))
|
||||||
w.SetHeader("X-Api-Range-Total", fmt.Sprintf("%d", metadata.Filtered))
|
w.SetHeader("X-Api-Range-Total", fmt.Sprintf("%d", metadata.Filtered))
|
||||||
w.SetHeader("X-Api-Range-Size", fmt.Sprintf("%d", metadata.Count))
|
w.SetHeader("X-Api-Range-Size", fmt.Sprintf("%d", metadata.Count))
|
||||||
|
w.SetHeader("X-Api-Range-From", fmt.Sprintf("%d", metadata.Offset))
|
||||||
|
w.SetHeader("X-Api-Range-Etotal", fmt.Sprintf("%d", metadata.Filtered))
|
||||||
|
w.SetHeader("X-Api-Modelname", tableName)
|
||||||
|
|
||||||
// Format response based on response format option
|
// Format response based on response format option
|
||||||
switch options.ResponseFormat {
|
switch options.ResponseFormat {
|
||||||
|
|||||||
@@ -225,12 +225,13 @@ func (h *Handler) parseOptionsFromHeaders(r common.Request, model interface{}) E
|
|||||||
limitValueParts := strings.Split(limitValue, ",")
|
limitValueParts := strings.Split(limitValue, ",")
|
||||||
|
|
||||||
if len(limitValueParts) > 1 {
|
if len(limitValueParts) > 1 {
|
||||||
if offset, err := strconv.Atoi(limitValueParts[0]); err == nil {
|
if limit, err := strconv.Atoi(limitValueParts[0]); err == nil {
|
||||||
options.Offset = &offset
|
|
||||||
}
|
|
||||||
if limit, err := strconv.Atoi(limitValueParts[1]); err == nil {
|
|
||||||
options.Limit = &limit
|
options.Limit = &limit
|
||||||
}
|
}
|
||||||
|
if offset, err := strconv.Atoi(limitValueParts[1]); err == nil {
|
||||||
|
options.Offset = &offset
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
if limit, err := strconv.Atoi(limitValueParts[0]); err == nil {
|
if limit, err := strconv.Atoi(limitValueParts[0]); err == nil {
|
||||||
options.Limit = &limit
|
options.Limit = &limit
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_login_at TIMESTAMP,
|
last_login_at TIMESTAMP,
|
||||||
|
-- Program-level user mapping
|
||||||
|
program_user_id INTEGER DEFAULT 0,
|
||||||
|
program_user_table VARCHAR(255) DEFAULT '',
|
||||||
-- OAuth2 fields
|
-- OAuth2 fields
|
||||||
remote_id VARCHAR(255), -- Provider's user ID (e.g., Google sub, GitHub id)
|
remote_id VARCHAR(255), -- Provider's user ID (e.g., Google sub, GitHub id)
|
||||||
auth_provider VARCHAR(50), -- 'local', 'google', 'github', 'microsoft', 'facebook', etc.
|
auth_provider VARCHAR(50), -- 'local', 'google', 'github', 'microsoft', 'facebook', etc.
|
||||||
@@ -99,6 +102,8 @@ DECLARE
|
|||||||
v_expires_at TIMESTAMP;
|
v_expires_at TIMESTAMP;
|
||||||
v_ip_address TEXT;
|
v_ip_address TEXT;
|
||||||
v_user_agent TEXT;
|
v_user_agent TEXT;
|
||||||
|
v_program_user_id INTEGER;
|
||||||
|
v_program_user_table TEXT;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Extract login request fields
|
-- Extract login request fields
|
||||||
v_username := p_request->>'username';
|
v_username := p_request->>'username';
|
||||||
@@ -106,8 +111,8 @@ BEGIN
|
|||||||
v_user_agent := p_request->'claims'->>'user_agent';
|
v_user_agent := p_request->'claims'->>'user_agent';
|
||||||
|
|
||||||
-- Validate user credentials
|
-- Validate user credentials
|
||||||
SELECT id, username, email, password, user_level, roles
|
SELECT id, username, email, password, user_level, roles, program_user_id, program_user_table
|
||||||
INTO v_user_id, v_username, v_email, v_password_hash, v_user_level, v_roles
|
INTO v_user_id, v_username, v_email, v_password_hash, v_user_level, v_roles, v_program_user_id, v_program_user_table
|
||||||
FROM users
|
FROM users
|
||||||
WHERE username = v_username AND is_active = true;
|
WHERE username = v_username AND is_active = true;
|
||||||
|
|
||||||
@@ -146,7 +151,9 @@ BEGIN
|
|||||||
'email', v_email,
|
'email', v_email,
|
||||||
'user_level', v_user_level,
|
'user_level', v_user_level,
|
||||||
'roles', string_to_array(COALESCE(v_roles, ''), ','),
|
'roles', string_to_array(COALESCE(v_roles, ''), ','),
|
||||||
'session_id', v_session_token
|
'session_id', v_session_token,
|
||||||
|
'program_user_id', COALESCE(v_program_user_id, 0),
|
||||||
|
'program_user_table', COALESCE(v_program_user_table, '')
|
||||||
),
|
),
|
||||||
'expires_in', 86400 -- 24 hours in seconds
|
'expires_in', 86400 -- 24 hours in seconds
|
||||||
);
|
);
|
||||||
@@ -195,12 +202,16 @@ DECLARE
|
|||||||
v_user_level INTEGER;
|
v_user_level INTEGER;
|
||||||
v_roles TEXT;
|
v_roles TEXT;
|
||||||
v_session_id TEXT;
|
v_session_id TEXT;
|
||||||
|
v_program_user_id INTEGER;
|
||||||
|
v_program_user_table TEXT;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Query session and user data
|
-- Query session and user data
|
||||||
SELECT
|
SELECT
|
||||||
s.user_id, u.username, u.email, u.user_level, u.roles, s.session_token
|
s.user_id, u.username, u.email, u.user_level, u.roles, s.session_token,
|
||||||
|
u.program_user_id, u.program_user_table
|
||||||
INTO
|
INTO
|
||||||
v_user_id, v_username, v_email, v_user_level, v_roles, v_session_id
|
v_user_id, v_username, v_email, v_user_level, v_roles, v_session_id,
|
||||||
|
v_program_user_id, v_program_user_table
|
||||||
FROM user_sessions s
|
FROM user_sessions s
|
||||||
JOIN users u ON s.user_id = u.id
|
JOIN users u ON s.user_id = u.id
|
||||||
WHERE s.session_token = p_session_token
|
WHERE s.session_token = p_session_token
|
||||||
@@ -222,7 +233,9 @@ BEGIN
|
|||||||
'email', v_email,
|
'email', v_email,
|
||||||
'user_level', v_user_level,
|
'user_level', v_user_level,
|
||||||
'session_id', v_session_id,
|
'session_id', v_session_id,
|
||||||
'roles', string_to_array(COALESCE(v_roles, ''), ',')
|
'roles', string_to_array(COALESCE(v_roles, ''), ','),
|
||||||
|
'program_user_id', COALESCE(v_program_user_id, 0),
|
||||||
|
'program_user_table', COALESCE(v_program_user_table, '')
|
||||||
);
|
);
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
@@ -266,10 +279,14 @@ DECLARE
|
|||||||
v_expires_at TIMESTAMP;
|
v_expires_at TIMESTAMP;
|
||||||
v_ip_address TEXT;
|
v_ip_address TEXT;
|
||||||
v_user_agent TEXT;
|
v_user_agent TEXT;
|
||||||
|
v_program_user_id INTEGER;
|
||||||
|
v_program_user_table TEXT;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Verify old session exists and is valid
|
-- Verify old session exists and is valid
|
||||||
SELECT s.user_id, u.username, u.email, u.user_level, u.roles, s.ip_address, s.user_agent
|
SELECT s.user_id, u.username, u.email, u.user_level, u.roles, s.ip_address, s.user_agent,
|
||||||
INTO v_user_id, v_username, v_email, v_user_level, v_roles, v_ip_address, v_user_agent
|
u.program_user_id, u.program_user_table
|
||||||
|
INTO v_user_id, v_username, v_email, v_user_level, v_roles, v_ip_address, v_user_agent,
|
||||||
|
v_program_user_id, v_program_user_table
|
||||||
FROM user_sessions s
|
FROM user_sessions s
|
||||||
JOIN users u ON s.user_id = u.id
|
JOIN users u ON s.user_id = u.id
|
||||||
WHERE s.session_token = p_old_session_token
|
WHERE s.session_token = p_old_session_token
|
||||||
@@ -302,7 +319,9 @@ BEGIN
|
|||||||
'email', v_email,
|
'email', v_email,
|
||||||
'user_level', v_user_level,
|
'user_level', v_user_level,
|
||||||
'session_id', v_new_session_token,
|
'session_id', v_new_session_token,
|
||||||
'roles', string_to_array(COALESCE(v_roles, ''), ',')
|
'roles', string_to_array(COALESCE(v_roles, ''), ','),
|
||||||
|
'program_user_id', COALESCE(v_program_user_id, 0),
|
||||||
|
'program_user_table', COALESCE(v_program_user_table, '')
|
||||||
);
|
);
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
@@ -439,6 +458,8 @@ DECLARE
|
|||||||
v_ip_address TEXT;
|
v_ip_address TEXT;
|
||||||
v_user_agent TEXT;
|
v_user_agent TEXT;
|
||||||
v_roles_array TEXT[];
|
v_roles_array TEXT[];
|
||||||
|
v_program_user_id INTEGER;
|
||||||
|
v_program_user_table TEXT;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Extract registration request fields
|
-- Extract registration request fields
|
||||||
v_username := p_request->>'username';
|
v_username := p_request->>'username';
|
||||||
@@ -447,6 +468,8 @@ BEGIN
|
|||||||
v_user_level := COALESCE((p_request->>'user_level')::integer, 0);
|
v_user_level := COALESCE((p_request->>'user_level')::integer, 0);
|
||||||
v_ip_address := p_request->'claims'->>'ip_address';
|
v_ip_address := p_request->'claims'->>'ip_address';
|
||||||
v_user_agent := p_request->'claims'->>'user_agent';
|
v_user_agent := p_request->'claims'->>'user_agent';
|
||||||
|
v_program_user_id := COALESCE((p_request->>'program_user_id')::integer, 0);
|
||||||
|
v_program_user_table := COALESCE(p_request->>'program_user_table', '');
|
||||||
|
|
||||||
-- Convert roles array from JSON to comma-separated string
|
-- Convert roles array from JSON to comma-separated string
|
||||||
SELECT array_to_string(ARRAY(SELECT jsonb_array_elements_text(p_request->'roles')), ',')
|
SELECT array_to_string(ARRAY(SELECT jsonb_array_elements_text(p_request->'roles')), ',')
|
||||||
@@ -485,8 +508,8 @@ BEGIN
|
|||||||
-- v_password := crypt(v_password, gen_salt('bf'));
|
-- v_password := crypt(v_password, gen_salt('bf'));
|
||||||
|
|
||||||
-- Create new user
|
-- Create new user
|
||||||
INSERT INTO users (username, email, password, user_level, roles, is_active, created_at, updated_at)
|
INSERT INTO users (username, email, password, user_level, roles, is_active, created_at, updated_at, program_user_id, program_user_table)
|
||||||
VALUES (v_username, v_email, v_password, v_user_level, v_roles, true, now(), now())
|
VALUES (v_username, v_email, v_password, v_user_level, v_roles, true, now(), now(), v_program_user_id, v_program_user_table)
|
||||||
RETURNING id INTO v_user_id;
|
RETURNING id INTO v_user_id;
|
||||||
|
|
||||||
-- Generate session token
|
-- Generate session token
|
||||||
@@ -512,7 +535,9 @@ BEGIN
|
|||||||
'email', v_email,
|
'email', v_email,
|
||||||
'user_level', v_user_level,
|
'user_level', v_user_level,
|
||||||
'roles', string_to_array(COALESCE(v_roles, ''), ','),
|
'roles', string_to_array(COALESCE(v_roles, ''), ','),
|
||||||
'session_id', v_session_token
|
'session_id', v_session_token,
|
||||||
|
'program_user_id', v_program_user_id,
|
||||||
|
'program_user_table', v_program_user_table
|
||||||
),
|
),
|
||||||
'expires_in', 86400 -- 24 hours in seconds
|
'expires_in', 86400 -- 24 hours in seconds
|
||||||
);
|
);
|
||||||
@@ -671,12 +696,16 @@ DECLARE
|
|||||||
v_user_level INTEGER;
|
v_user_level INTEGER;
|
||||||
v_roles TEXT;
|
v_roles TEXT;
|
||||||
v_expires_at TIMESTAMP;
|
v_expires_at TIMESTAMP;
|
||||||
|
v_program_user_id INTEGER;
|
||||||
|
v_program_user_table TEXT;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Query session and user data from user_sessions table
|
-- Query session and user data from user_sessions table
|
||||||
SELECT
|
SELECT
|
||||||
s.user_id, u.username, u.email, u.user_level, u.roles, s.expires_at
|
s.user_id, u.username, u.email, u.user_level, u.roles, s.expires_at,
|
||||||
|
u.program_user_id, u.program_user_table
|
||||||
INTO
|
INTO
|
||||||
v_user_id, v_username, v_email, v_user_level, v_roles, v_expires_at
|
v_user_id, v_username, v_email, v_user_level, v_roles, v_expires_at,
|
||||||
|
v_program_user_id, v_program_user_table
|
||||||
FROM user_sessions s
|
FROM user_sessions s
|
||||||
JOIN users u ON s.user_id = u.id
|
JOIN users u ON s.user_id = u.id
|
||||||
WHERE s.session_token = p_session_token
|
WHERE s.session_token = p_session_token
|
||||||
@@ -698,7 +727,9 @@ BEGIN
|
|||||||
'email', v_email,
|
'email', v_email,
|
||||||
'user_level', v_user_level,
|
'user_level', v_user_level,
|
||||||
'session_id', p_session_token,
|
'session_id', p_session_token,
|
||||||
'roles', string_to_array(COALESCE(v_roles, ''), ',')
|
'roles', string_to_array(COALESCE(v_roles, ''), ','),
|
||||||
|
'program_user_id', COALESCE(v_program_user_id, 0),
|
||||||
|
'program_user_table', COALESCE(v_program_user_table, '')
|
||||||
);
|
);
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
@@ -815,10 +846,12 @@ DECLARE
|
|||||||
v_email TEXT;
|
v_email TEXT;
|
||||||
v_user_level INTEGER;
|
v_user_level INTEGER;
|
||||||
v_roles TEXT;
|
v_roles TEXT;
|
||||||
|
v_program_user_id INTEGER;
|
||||||
|
v_program_user_table TEXT;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Query user data
|
-- Query user data
|
||||||
SELECT username, email, user_level, roles
|
SELECT username, email, user_level, roles, program_user_id, program_user_table
|
||||||
INTO v_username, v_email, v_user_level, v_roles
|
INTO v_username, v_email, v_user_level, v_roles, v_program_user_id, v_program_user_table
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = p_user_id
|
WHERE id = p_user_id
|
||||||
AND is_active = true;
|
AND is_active = true;
|
||||||
@@ -837,7 +870,9 @@ BEGIN
|
|||||||
'user_name', v_username,
|
'user_name', v_username,
|
||||||
'email', v_email,
|
'email', v_email,
|
||||||
'user_level', v_user_level,
|
'user_level', v_user_level,
|
||||||
'roles', string_to_array(COALESCE(v_roles, ''), ',')
|
'roles', string_to_array(COALESCE(v_roles, ''), ','),
|
||||||
|
'program_user_id', COALESCE(v_program_user_id, 0),
|
||||||
|
'program_user_table', COALESCE(v_program_user_table, '')
|
||||||
);
|
);
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ type UserContext struct {
|
|||||||
Claims map[string]any `json:"claims"`
|
Claims map[string]any `json:"claims"`
|
||||||
Meta map[string]any `json:"meta"` // Additional metadata that can hold any JSON-serializable values
|
Meta map[string]any `json:"meta"` // Additional metadata that can hold any JSON-serializable values
|
||||||
TwoFactorEnabled bool `json:"two_factor_enabled"` // Indicates if 2FA is enabled for this user
|
TwoFactorEnabled bool `json:"two_factor_enabled"` // Indicates if 2FA is enabled for this user
|
||||||
|
ProgramUserID int `json:"program_user_id"`
|
||||||
|
ProgramUserTable string `json:"program_user_table"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginRequest contains credentials for login
|
// LoginRequest contains credentials for login
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ func FromConfigInstanceToServerConfig(sic *config.ServerInstanceConfig, handler
|
|||||||
Description: sic.Description,
|
Description: sic.Description,
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
GZIP: sic.GZIP,
|
GZIP: sic.GZIP,
|
||||||
|
HTTP2: sic.HTTP2,
|
||||||
|
|
||||||
SSLCert: sic.SSLCert,
|
SSLCert: sic.SSLCert,
|
||||||
SSLKey: sic.SSLKey,
|
SSLKey: sic.SSLKey,
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ type Config struct {
|
|||||||
// GZIP compression support
|
// GZIP compression support
|
||||||
GZIP bool
|
GZIP bool
|
||||||
|
|
||||||
|
// HTTP2 enables HTTP/2 with the Extended CONNECT protocol (RFC 8441) for WebSocket support.
|
||||||
|
// Requires TLS; pair with SSLCert/SSLKey, SelfSignedSSL, or AutoTLS.
|
||||||
|
HTTP2 bool
|
||||||
|
|
||||||
// TLS/HTTPS configuration options (mutually exclusive)
|
// TLS/HTTPS configuration options (mutually exclusive)
|
||||||
// Option 1: Provide certificate and key files directly
|
// Option 1: Provide certificate and key files directly
|
||||||
SSLCert string
|
SSLCert string
|
||||||
|
|||||||
+32
-8
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -461,15 +462,38 @@ func newInstance(cfg Config) (*serverInstance, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create gracefulServer
|
// Create gracefulServer
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: handler,
|
||||||
|
ReadTimeout: cfg.ReadTimeout,
|
||||||
|
WriteTimeout: cfg.WriteTimeout,
|
||||||
|
IdleTimeout: cfg.IdleTimeout,
|
||||||
|
TLSConfig: tlsConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable HTTP/2 with Extended CONNECT (RFC 8441) for WebSocket-over-H2 support.
|
||||||
|
// The GODEBUG=http2xconnect=1 flag is read by net/http's init(); setting it here
|
||||||
|
// ensures it propagates to subprocesses and any future process restarts.
|
||||||
|
// For the current process, set GODEBUG=http2xconnect=1 in the environment before launch.
|
||||||
|
if cfg.HTTP2 {
|
||||||
|
if existing := os.Getenv("GODEBUG"); !strings.Contains(existing, "http2xconnect=1") {
|
||||||
|
if existing == "" {
|
||||||
|
os.Setenv("GODEBUG", "http2xconnect=1")
|
||||||
|
} else {
|
||||||
|
os.Setenv("GODEBUG", existing+",http2xconnect=1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if httpServer.HTTP2 == nil {
|
||||||
|
httpServer.HTTP2 = &http.HTTP2Config{}
|
||||||
|
}
|
||||||
|
httpServer.Protocols.SetHTTP2(true)
|
||||||
|
httpServer.Protocols.SetUnencryptedHTTP2(true)
|
||||||
|
} else {
|
||||||
|
httpServer.Protocols.SetHTTP2(false)
|
||||||
|
}
|
||||||
|
|
||||||
gracefulSrv := &gracefulServer{
|
gracefulSrv := &gracefulServer{
|
||||||
server: &http.Server{
|
server: httpServer,
|
||||||
Addr: addr,
|
|
||||||
Handler: handler,
|
|
||||||
ReadTimeout: cfg.ReadTimeout,
|
|
||||||
WriteTimeout: cfg.WriteTimeout,
|
|
||||||
IdleTimeout: cfg.IdleTimeout,
|
|
||||||
TLSConfig: tlsConfig,
|
|
||||||
},
|
|
||||||
shutdownTimeout: cfg.ShutdownTimeout,
|
shutdownTimeout: cfg.ShutdownTimeout,
|
||||||
drainTimeout: cfg.DrainTimeout,
|
drainTimeout: cfg.DrainTimeout,
|
||||||
shutdownComplete: make(chan struct{}),
|
shutdownComplete: make(chan struct{}),
|
||||||
|
|||||||
Reference in New Issue
Block a user