Compare commits

..

9 Commits

Author SHA1 Message Date
Hein 8a06aacfb2 fix(cors): update CORS headers handling for requests
Tests / Integration Tests (push) Failing after 1s
Tests / Unit Tests (push) Failing after 22s
Build , Vet Test, and Lint / Build (push) Successful in 1m1s
Build , Vet Test, and Lint / Lint Code (push) Successful in 1m19s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 1m35s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 1m36s
* Reflect request origin for Access-Control-Allow-Origin
* Set Vary header for caching based on origin
* Allow specific headers from preflight requests
* Enable credentials only for specific origins
2026-07-01 12:27:39 +02:00
Hein 705c4f8001 fix(manager): ensure HTTP1 is set when HTTP2 is disabled
Tests / Integration Tests (push) Failing after 1s
Tests / Unit Tests (push) Failing after 1m41s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 3m50s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 3m53s
Build , Vet Test, and Lint / Build (push) Successful in 3m55s
Build , Vet Test, and Lint / Lint Code (push) Successful in 4m3s
2026-06-30 13:54:56 +02:00
Hein d648614611 feat(config): add PanicHandler to Config for custom recovery 2026-06-30 13:49:51 +02:00
Hein 3f86eb0f06 feat(config): add HTTP2 field to ServerInstanceConfig and align with server.Config 2026-06-30 13:36:06 +02:00
Hein 3dac55cb19 fix: Set http2 based on prop 2026-06-30 13:29:55 +02:00
Hein bbb2c6d127 feat(server): add HTTP2 support in server configuration 2026-06-30 11:33:31 +02:00
Hein 3fec7b1a90 fix(handler): update Content-Range headers for API response
Build , Vet Test, and Lint / Lint Code (push) Failing after 0s
Build , Vet Test, and Lint / Build (push) Failing after 0s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Failing after 1s
Tests / Unit Tests (push) Failing after 1s
Tests / Integration Tests (push) Failing after 0s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 48s
* change Content-Range format to include 'items'
* add X-Api-Range-From and X-Api-Modelname headers
* add X-Api-Range-Etotal header for total filtered items
2026-06-24 10:02:54 +02:00
Hein 910390f62d fix(headers): correct order of limit and offset parsing 2026-06-24 09:47:14 +02:00
Hein b9bed67bd7 feat(security): add program user ID and table to user context
Build , Vet Test, and Lint / Lint Code (push) Failing after 0s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Failing after 0s
Build , Vet Test, and Lint / Build (push) Failing after 1s
Tests / Unit Tests (push) Failing after 0s
Tests / Integration Tests (push) Failing after 1s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Failing after 14m57s
2026-06-23 10:18:22 +02:00
9 changed files with 148 additions and 47 deletions
+21 -14
View File
@@ -115,32 +115,39 @@ func GetHeadSpecHeaders() []string {
// SetCORSHeaders sets CORS headers on a response writer
func SetCORSHeaders(w ResponseWriter, r Request, config CORSConfig) {
// Set allowed origins
// if len(config.AllowedOrigins) > 0 {
// w.SetHeader("Access-Control-Allow-Origin", strings.Join(config.AllowedOrigins, ", "))
// }
// Todo origin list parsing
w.SetHeader("Access-Control-Allow-Origin", "*")
// Reflect the request origin; fall back to wildcard only when no origin is present
origin := r.Header("Origin")
if origin == "" {
origin = "*"
} else {
// Vary must be set so caches don't serve one origin's response to another
httpW := w.UnderlyingResponseWriter()
httpW.Header().Set("Vary", "Origin")
}
w.SetHeader("Access-Control-Allow-Origin", origin)
// Set allowed methods
if len(config.AllowedMethods) > 0 {
w.SetHeader("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", "))
}
// Set allowed headers
// if len(config.AllowedHeaders) > 0 {
// w.SetHeader("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
// }
w.SetHeader("Access-Control-Allow-Headers", "*")
// Reflect the preflight request headers when present; otherwise use the explicit config list
requestedHeaders := r.Header("Access-Control-Request-Headers")
if requestedHeaders != "" {
w.SetHeader("Access-Control-Allow-Headers", requestedHeaders)
} else if len(config.AllowedHeaders) > 0 {
w.SetHeader("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
}
// Set max age
if config.MaxAge > 0 {
w.SetHeader("Access-Control-Max-Age", fmt.Sprintf("%d", config.MaxAge))
}
// Allow credentials
w.SetHeader("Access-Control-Allow-Credentials", "true")
// Allow credentials only when a specific origin is reflected (not wildcard)
if origin != "*" {
w.SetHeader("Access-Control-Allow-Credentials", "true")
}
// Expose headers that clients can read
exposeHeaders := config.AllowedHeaders
+4
View File
@@ -50,6 +50,10 @@ type ServerInstanceConfig struct {
// GZIP enables GZIP compression middleware
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)
// Option 1: Provide certificate and key files directly
SSLCert string `mapstructure:"ssl_cert"`
+4 -1
View File
@@ -2711,9 +2711,12 @@ func (h *Handler) sendFormattedResponse(w common.ResponseWriter, data interface{
}
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-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
switch options.ResponseFormat {
+5 -4
View File
@@ -225,12 +225,13 @@ func (h *Handler) parseOptionsFromHeaders(r common.Request, model interface{}) E
limitValueParts := strings.Split(limitValue, ",")
if len(limitValueParts) > 1 {
if offset, err := strconv.Atoi(limitValueParts[0]); err == nil {
options.Offset = &offset
}
if limit, err := strconv.Atoi(limitValueParts[1]); err == nil {
if limit, err := strconv.Atoi(limitValueParts[0]); err == nil {
options.Limit = &limit
}
if offset, err := strconv.Atoi(limitValueParts[1]); err == nil {
options.Offset = &offset
}
} else {
if limit, err := strconv.Atoi(limitValueParts[0]); err == nil {
options.Limit = &limit
+53 -18
View File
@@ -13,6 +13,9 @@ CREATE TABLE IF NOT EXISTS users (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP,
-- Program-level user mapping
program_user_id INTEGER DEFAULT 0,
program_user_table VARCHAR(255) DEFAULT '',
-- OAuth2 fields
remote_id VARCHAR(255), -- Provider's user ID (e.g., Google sub, GitHub id)
auth_provider VARCHAR(50), -- 'local', 'google', 'github', 'microsoft', 'facebook', etc.
@@ -99,6 +102,8 @@ DECLARE
v_expires_at TIMESTAMP;
v_ip_address TEXT;
v_user_agent TEXT;
v_program_user_id INTEGER;
v_program_user_table TEXT;
BEGIN
-- Extract login request fields
v_username := p_request->>'username';
@@ -106,8 +111,8 @@ BEGIN
v_user_agent := p_request->'claims'->>'user_agent';
-- Validate user credentials
SELECT id, username, email, password, user_level, roles
INTO v_user_id, v_username, v_email, v_password_hash, v_user_level, v_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, v_program_user_id, v_program_user_table
FROM users
WHERE username = v_username AND is_active = true;
@@ -146,7 +151,9 @@ BEGIN
'email', v_email,
'user_level', v_user_level,
'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
);
@@ -195,12 +202,16 @@ DECLARE
v_user_level INTEGER;
v_roles TEXT;
v_session_id TEXT;
v_program_user_id INTEGER;
v_program_user_table TEXT;
BEGIN
-- Query session and user data
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
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
JOIN users u ON s.user_id = u.id
WHERE s.session_token = p_session_token
@@ -222,7 +233,9 @@ BEGIN
'email', v_email,
'user_level', v_user_level,
'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;
$$ LANGUAGE plpgsql;
@@ -266,10 +279,14 @@ DECLARE
v_expires_at TIMESTAMP;
v_ip_address TEXT;
v_user_agent TEXT;
v_program_user_id INTEGER;
v_program_user_table TEXT;
BEGIN
-- 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
INTO v_user_id, v_username, v_email, v_user_level, v_roles, v_ip_address, v_user_agent
SELECT s.user_id, u.username, u.email, u.user_level, u.roles, s.ip_address, s.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
JOIN users u ON s.user_id = u.id
WHERE s.session_token = p_old_session_token
@@ -302,7 +319,9 @@ BEGIN
'email', v_email,
'user_level', v_user_level,
'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;
$$ LANGUAGE plpgsql;
@@ -439,6 +458,8 @@ DECLARE
v_ip_address TEXT;
v_user_agent TEXT;
v_roles_array TEXT[];
v_program_user_id INTEGER;
v_program_user_table TEXT;
BEGIN
-- Extract registration request fields
v_username := p_request->>'username';
@@ -447,6 +468,8 @@ BEGIN
v_user_level := COALESCE((p_request->>'user_level')::integer, 0);
v_ip_address := p_request->'claims'->>'ip_address';
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
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'));
-- Create new user
INSERT INTO users (username, email, password, user_level, roles, is_active, created_at, updated_at)
VALUES (v_username, v_email, v_password, v_user_level, v_roles, true, now(), now())
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(), v_program_user_id, v_program_user_table)
RETURNING id INTO v_user_id;
-- Generate session token
@@ -512,7 +535,9 @@ BEGIN
'email', v_email,
'user_level', v_user_level,
'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
);
@@ -671,12 +696,16 @@ DECLARE
v_user_level INTEGER;
v_roles TEXT;
v_expires_at TIMESTAMP;
v_program_user_id INTEGER;
v_program_user_table TEXT;
BEGIN
-- Query session and user data from user_sessions table
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
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
JOIN users u ON s.user_id = u.id
WHERE s.session_token = p_session_token
@@ -698,7 +727,9 @@ BEGIN
'email', v_email,
'user_level', v_user_level,
'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;
$$ LANGUAGE plpgsql;
@@ -815,10 +846,12 @@ DECLARE
v_email TEXT;
v_user_level INTEGER;
v_roles TEXT;
v_program_user_id INTEGER;
v_program_user_table TEXT;
BEGIN
-- Query user data
SELECT username, email, user_level, roles
INTO v_username, v_email, v_user_level, v_roles
SELECT username, email, user_level, roles, program_user_id, program_user_table
INTO v_username, v_email, v_user_level, v_roles, v_program_user_id, v_program_user_table
FROM users
WHERE id = p_user_id
AND is_active = true;
@@ -837,7 +870,9 @@ BEGIN
'user_name', v_username,
'email', v_email,
'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;
$$ LANGUAGE plpgsql;
+2
View File
@@ -18,6 +18,8 @@ type UserContext struct {
Claims map[string]any `json:"claims"`
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
ProgramUserID int `json:"program_user_id"`
ProgramUserTable string `json:"program_user_table"`
}
// LoginRequest contains credentials for login
+1
View File
@@ -16,6 +16,7 @@ func FromConfigInstanceToServerConfig(sic *config.ServerInstanceConfig, handler
Description: sic.Description,
Handler: handler,
GZIP: sic.GZIP,
HTTP2: sic.HTTP2,
SSLCert: sic.SSLCert,
SSLKey: sic.SSLKey,
+8
View File
@@ -19,6 +19,10 @@ type Config struct {
// GZIP compression support
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)
// Option 1: Provide certificate and key files directly
SSLCert string
@@ -38,6 +42,10 @@ type Config struct {
// AutoTLSEmail is the email for Let's Encrypt registration (optional but recommended)
AutoTLSEmail string
// PanicHandler is called when a request handler panics.
// If nil, the default middleware.PanicRecovery is used (logs, records metric, returns 500).
PanicHandler func(w http.ResponseWriter, r *http.Request, rcv any)
// Graceful shutdown configuration
// ShutdownTimeout is the maximum time to wait for graceful shutdown
// Default: 30 seconds
+50 -10
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"sync"
"sync/atomic"
"syscall"
@@ -451,8 +452,19 @@ func newInstance(cfg Config) (*serverInstance, error) {
handler = gz(handler)
}
// Wrap with the panic recovery middleware
handler = middleware.PanicRecovery(handler)
// Wrap with panic recovery — use caller-supplied handler if provided
if cfg.PanicHandler != nil {
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rcv := recover(); rcv != nil {
cfg.PanicHandler(w, r, rcv)
}
}()
handler.ServeHTTP(w, r)
})
} else {
handler = middleware.PanicRecovery(handler)
}
// Configure TLS if any TLS option is enabled
tlsConfig, certFile, keyFile, err := configureTLS(cfg)
@@ -461,15 +473,43 @@ func newInstance(cfg Config) (*serverInstance, error) {
}
// 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 httpServer.Protocols == nil {
httpServer.Protocols = &http.Protocols{}
httpServer.Protocols.SetHTTP1(true)
}
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.SetHTTP1(true)
httpServer.Protocols.SetHTTP2(false)
}
gracefulSrv := &gracefulServer{
server: &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
TLSConfig: tlsConfig,
},
server: httpServer,
shutdownTimeout: cfg.ShutdownTimeout,
drainTimeout: cfg.DrainTimeout,
shutdownComplete: make(chan struct{}),