// Package server implements the HTTP server and RPC dispatch. // Mirrors: odoo/http.py, odoo/service/server.py package server import ( "context" "encoding/json" "fmt" "log" "net/http" "strings" "time" "github.com/jackc/pgx/v5/pgxpool" "odoo-go/pkg/orm" "odoo-go/pkg/tools" ) // Server is the main Odoo HTTP server. // Mirrors: odoo/service/server.py ThreadedServer type Server struct { config *tools.Config pool *pgxpool.Pool mux *http.ServeMux sessions *SessionStore } // New creates a new server instance. func New(cfg *tools.Config, pool *pgxpool.Pool) *Server { s := &Server{ config: cfg, pool: pool, mux: http.NewServeMux(), sessions: NewSessionStore(24 * time.Hour), } s.registerRoutes() return s } // registerRoutes sets up HTTP routes. // Mirrors: odoo/http.py Application._setup_routes() func (s *Server) registerRoutes() { // Webclient HTML shell s.mux.HandleFunc("/web", s.handleWebClient) s.mux.HandleFunc("/web/", s.handleWebRoute) s.mux.HandleFunc("/odoo", s.handleWebClient) s.mux.HandleFunc("/odoo/", s.handleWebClient) // Login page s.mux.HandleFunc("/web/login", s.handleLogin) // JSON-RPC endpoint (main API) s.mux.HandleFunc("/jsonrpc", s.handleJSONRPC) s.mux.HandleFunc("/web/dataset/call_kw", s.handleCallKW) s.mux.HandleFunc("/web/dataset/call_kw/", s.handleCallKW) // Session endpoints s.mux.HandleFunc("/web/session/authenticate", s.handleAuthenticate) s.mux.HandleFunc("/web/session/get_session_info", s.handleSessionInfo) s.mux.HandleFunc("/web/session/check", s.handleSessionCheck) s.mux.HandleFunc("/web/session/modules", s.handleSessionModules) // Webclient endpoints s.mux.HandleFunc("/web/webclient/load_menus", s.handleLoadMenus) s.mux.HandleFunc("/web/webclient/translations", s.handleTranslations) s.mux.HandleFunc("/web/webclient/version_info", s.handleVersionInfo) s.mux.HandleFunc("/web/webclient/bootstrap_translations", s.handleBootstrapTranslations) // Action loading s.mux.HandleFunc("/web/action/load", s.handleActionLoad) // Database endpoints s.mux.HandleFunc("/web/database/list", s.handleDBList) // Setup wizard s.mux.HandleFunc("/web/setup", s.handleSetup) s.mux.HandleFunc("/web/setup/install", s.handleSetupInstall) // PWA manifest s.mux.HandleFunc("/web/manifest.webmanifest", s.handleManifest) // Health check s.mux.HandleFunc("/health", s.handleHealth) // Static files (catch-all for //static/...) // NOTE: must be last since it's a broad pattern } // handleWebRoute dispatches /web/* sub-routes or falls back to static files. func (s *Server) handleWebRoute(w http.ResponseWriter, r *http.Request) { path := r.URL.Path // Known sub-routes are handled by specific handlers above. // Anything under /web/static/ is a static file request. if strings.HasPrefix(path, "/web/static/") { s.handleStatic(w, r) return } // For all other /web/* paths, serve the webclient (SPA routing) s.handleWebClient(w, r) } // Start starts the HTTP server. func (s *Server) Start() error { addr := fmt.Sprintf("%s:%d", s.config.HTTPInterface, s.config.HTTPPort) log.Printf("odoo: HTTP service running on %s", addr) srv := &http.Server{ Addr: addr, Handler: AuthMiddleware(s.sessions, s.mux), ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, } return srv.ListenAndServe() } // --- JSON-RPC --- // Mirrors: odoo/http.py JsonRPCDispatcher // JSONRPCRequest is the JSON-RPC 2.0 request format. type JSONRPCRequest struct { JSONRPC string `json:"jsonrpc"` Method string `json:"method"` ID interface{} `json:"id"` Params json.RawMessage `json:"params"` } // JSONRPCResponse is the JSON-RPC 2.0 response format. type JSONRPCResponse struct { JSONRPC string `json:"jsonrpc"` ID interface{} `json:"id"` Result interface{} `json:"result,omitempty"` Error *RPCError `json:"error,omitempty"` } // RPCError represents a JSON-RPC error. type RPCError struct { Code int `json:"code"` Message string `json:"message"` Data interface{} `json:"data,omitempty"` } // CallKWParams mirrors the /web/dataset/call_kw parameters. type CallKWParams struct { Model string `json:"model"` Method string `json:"method"` Args []interface{} `json:"args"` KW Values `json:"kwargs"` } // Values is a generic key-value map for RPC parameters. type Values = map[string]interface{} func (s *Server) handleJSONRPC(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req JSONRPCRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.writeJSONRPC(w, req.ID, nil, &RPCError{ Code: -32700, Message: "Parse error", }) return } // Dispatch based on method s.writeJSONRPC(w, req.ID, map[string]string{"status": "ok"}, nil) } // handleCallKW handles ORM method calls via JSON-RPC. // Mirrors: odoo/service/model.py execute_kw() func (s *Server) handleCallKW(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req JSONRPCRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.writeJSONRPC(w, req.ID, nil, &RPCError{ Code: -32700, Message: "Parse error", }) return } var params CallKWParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { s.writeJSONRPC(w, req.ID, nil, &RPCError{ Code: -32602, Message: "Invalid params", }) return } // Extract UID from session, default to 1 (admin) if no session uid := int64(1) companyID := int64(1) if sess := GetSession(r); sess != nil { uid = sess.UID companyID = sess.CompanyID } // Create environment for this request env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{ Pool: s.pool, UID: uid, CompanyID: companyID, }) if err != nil { s.writeJSONRPC(w, req.ID, nil, &RPCError{ Code: -32000, Message: err.Error(), }) return } defer env.Close() // Dispatch ORM method result, rpcErr := s.dispatchORM(env, params) if rpcErr != nil { s.writeJSONRPC(w, req.ID, nil, rpcErr) return } if err := env.Commit(); err != nil { s.writeJSONRPC(w, req.ID, nil, &RPCError{ Code: -32000, Message: err.Error(), }) return } s.writeJSONRPC(w, req.ID, result, nil) } // checkAccess verifies the current user has permission for the operation. // Mirrors: odoo/addons/base/models/ir_model.py IrModelAccess.check() func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCError { if env.IsSuperuser() || env.UID() == 1 { return nil // Superuser bypasses all checks } perm := "perm_read" switch method { case "create": perm = "perm_create" case "write": perm = "perm_write" case "unlink": perm = "perm_unlink" } // Check if any ACL exists for this model var count int64 err := env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM ir_model_access a JOIN ir_model m ON m.id = a.model_id WHERE m.model = $1`, model).Scan(&count) if err != nil || count == 0 { return nil // No ACLs defined → open access (like Odoo superuser mode) } // Check if user's groups grant permission var granted bool err = env.Tx().QueryRow(env.Ctx(), fmt.Sprintf(` SELECT EXISTS( SELECT 1 FROM ir_model_access a JOIN ir_model m ON m.id = a.model_id LEFT JOIN res_groups_res_users_rel gu ON gu.res_groups_id = a.group_id WHERE m.model = $1 AND a.active = true AND a.%s = true AND (a.group_id IS NULL OR gu.res_users_id = $2) )`, perm), model, env.UID()).Scan(&granted) if err != nil { return nil // On error, allow (fail-open for now) } if !granted { return &RPCError{ Code: 403, Message: fmt.Sprintf("Access Denied: %s on %s", method, model), } } return nil } // dispatchORM dispatches an ORM method call. // Mirrors: odoo/service/model.py call_kw() func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interface{}, *RPCError) { // Check access control if err := s.checkAccess(env, params.Model, params.Method); err != nil { return nil, err } rs := env.Model(params.Model) switch params.Method { case "has_group": // Always return true for admin user, stub for now return true, nil case "check_access_rights": return true, nil case "fields_get": return fieldsGetForModel(params.Model), nil case "web_search_read": return handleWebSearchRead(env, params.Model, params) case "web_read": return handleWebRead(env, params.Model, params) case "get_views": return handleGetViews(env, params.Model, params) case "onchange": // Basic onchange: return empty value dict return map[string]interface{}{"value": map[string]interface{}{}}, nil case "search_read": domain := parseDomain(params.Args) fields := parseFields(params.KW) records, err := rs.SearchRead(domain, fields) if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } return records, nil case "read": ids := parseIDs(params.Args) fields := parseFields(params.KW) records, err := rs.Browse(ids...).Read(fields) if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } return records, nil case "create": vals := parseValues(params.Args) record, err := rs.Create(vals) if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } return record.ID(), nil case "write": ids := parseIDs(params.Args) vals := parseValuesAt(params.Args, 1) err := rs.Browse(ids...).Write(vals) if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } return true, nil case "unlink": ids := parseIDs(params.Args) err := rs.Browse(ids...).Unlink() if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } return true, nil case "search_count": domain := parseDomain(params.Args) count, err := rs.SearchCount(domain) if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } return count, nil case "name_get": ids := parseIDs(params.Args) names, err := rs.Browse(ids...).NameGet() if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } // Convert map to Odoo format: [[id, "name"], ...] var result [][]interface{} for id, name := range names { result = append(result, []interface{}{id, name}) } return result, nil case "name_search": // Basic name_search: search by name, return [[id, "name"], ...] nameStr := "" if len(params.Args) > 0 { nameStr, _ = params.Args[0].(string) } limit := 8 domain := orm.Domain{} if nameStr != "" { domain = orm.And(orm.Leaf("name", "ilike", nameStr)) } found, err := rs.Search(domain, orm.SearchOpts{Limit: limit}) if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } names, err := found.NameGet() if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } var nameResult [][]interface{} for id, name := range names { nameResult = append(nameResult, []interface{}{id, name}) } return nameResult, nil default: // Try registered business methods on the model model := orm.Registry.Get(params.Model) if model != nil && model.Methods != nil { if method, ok := model.Methods[params.Method]; ok { ids := parseIDs(params.Args) result, err := method(rs.Browse(ids...), params.Args[1:]...) if err != nil { return nil, &RPCError{Code: -32000, Message: err.Error()} } return result, nil } } return nil, &RPCError{ Code: -32601, Message: fmt.Sprintf("Method %q not found on %s", params.Method, params.Model), } } } // --- Session / Auth Endpoints --- func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req JSONRPCRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.writeJSONRPC(w, nil, nil, &RPCError{Code: -32700, Message: "Parse error"}) return } var params struct { DB string `json:"db"` Login string `json:"login"` Password string `json:"password"` } if err := json.Unmarshal(req.Params, ¶ms); err != nil { s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid params"}) return } // Query user by login var uid int64 var companyID int64 var partnerID int64 var hashedPw string var userName string err := s.pool.QueryRow(r.Context(), `SELECT u.id, u.password, u.company_id, u.partner_id, p.name FROM res_users u JOIN res_partner p ON p.id = u.partner_id WHERE u.login = $1 AND u.active = true`, params.Login, ).Scan(&uid, &hashedPw, &companyID, &partnerID, &userName) if err != nil { s.writeJSONRPC(w, req.ID, nil, &RPCError{ Code: 100, Message: "Access Denied: invalid login or password", }) return } // Check password (support both bcrypt and plaintext for migration) if !tools.CheckPassword(hashedPw, params.Password) && hashedPw != params.Password { s.writeJSONRPC(w, req.ID, nil, &RPCError{ Code: 100, Message: "Access Denied: invalid login or password", }) return } // Create session sess := s.sessions.New(uid, companyID, params.Login) // Set session cookie http.SetCookie(w, &http.Cookie{ Name: "session_id", Value: sess.ID, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, }) s.writeJSONRPC(w, req.ID, map[string]interface{}{ "uid": uid, "session_id": sess.ID, "company_id": companyID, "partner_id": partnerID, "is_admin": uid == 1, "name": userName, "username": params.Login, "server_version": "19.0-go", "server_version_info": []interface{}{19, 0, 0, "final", 0, "g"}, "db": s.config.DBName, }, nil) } func (s *Server) handleSessionInfo(w http.ResponseWriter, r *http.Request) { s.writeJSONRPC(w, nil, map[string]interface{}{ "uid": 1, "is_admin": true, "server_version": "19.0-go", "server_version_info": []interface{}{19, 0, 0, "final", 0, "g"}, "db": s.config.DBName, }, nil) } func (s *Server) handleDBList(w http.ResponseWriter, r *http.Request) { s.writeJSONRPC(w, nil, []string{s.config.DBName}, nil) } func (s *Server) handleVersionInfo(w http.ResponseWriter, r *http.Request) { s.writeJSONRPC(w, nil, map[string]interface{}{ "server_version": "19.0-go", "server_version_info": []interface{}{19, 0, 0, "final", 0, "g"}, "server_serie": "19.0", "protocol_version": 1, }, nil) } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { err := s.pool.Ping(context.Background()) if err != nil { http.Error(w, "unhealthy", http.StatusServiceUnavailable) return } w.WriteHeader(http.StatusOK) fmt.Fprint(w, "ok") } // --- Helpers --- func (s *Server) writeJSONRPC(w http.ResponseWriter, id interface{}, result interface{}, rpcErr *RPCError) { w.Header().Set("Content-Type", "application/json") resp := JSONRPCResponse{ JSONRPC: "2.0", ID: id, Result: result, Error: rpcErr, } json.NewEncoder(w).Encode(resp) } // parseDomain converts JSON-RPC domain args to orm.Domain. // JSON format: [["field", "op", value], ...] or ["&", ["field", "op", value], ...] func parseDomain(args []interface{}) orm.Domain { if len(args) == 0 { return nil } // First arg should be the domain list domainRaw, ok := args[0].([]interface{}) if !ok { return nil } if len(domainRaw) == 0 { return nil } var nodes []orm.DomainNode for _, item := range domainRaw { switch v := item.(type) { case string: // Operator: "&", "|", "!" nodes = append(nodes, orm.Operator(v)) case []interface{}: // Leaf: ["field", "op", value] if len(v) == 3 { field, _ := v[0].(string) op, _ := v[1].(string) nodes = append(nodes, orm.Leaf(field, op, v[2])) } } } // If we have multiple leaves without explicit operators, AND them together // (Odoo default: implicit AND between leaves) var leaves []orm.DomainNode for _, n := range nodes { leaves = append(leaves, n) } if len(leaves) == 0 { return nil } return orm.Domain(leaves) } func parseIDs(args []interface{}) []int64 { if len(args) == 0 { return nil } switch v := args[0].(type) { case []interface{}: ids := make([]int64, len(v)) for i, item := range v { switch n := item.(type) { case float64: ids[i] = int64(n) case int64: ids[i] = n } } return ids case float64: return []int64{int64(v)} } return nil } func parseFields(kw Values) []string { if kw == nil { return nil } fieldsRaw, ok := kw["fields"] if !ok { return nil } fieldsSlice, ok := fieldsRaw.([]interface{}) if !ok { return nil } fields := make([]string, len(fieldsSlice)) for i, f := range fieldsSlice { fields[i], _ = f.(string) } return fields } func parseValues(args []interface{}) orm.Values { if len(args) == 0 { return nil } vals, ok := args[0].(map[string]interface{}) if !ok { return nil } return orm.Values(vals) } func parseValuesAt(args []interface{}, idx int) orm.Values { if len(args) <= idx { return nil } vals, ok := args[idx].(map[string]interface{}) if !ok { return nil } return orm.Values(vals) }