- Portal: /my/* routes, signup, password reset, portal user support - Email Inbound: IMAP polling (go-imap/v2), thread matching - Discuss: mail.channel, long-polling bus, DM, unread count - Cron: ir.cron runner (goroutine scheduler) - Bank Import, CSV/Excel Import - Automation (ir.actions.server) - Fetchmail service - HR Payroll model - Various fixes across account, sale, stock, purchase, crm, hr, project Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1469 lines
41 KiB
Go
1469 lines
41 KiB
Go
// 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"
|
||
"sync"
|
||
"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
|
||
|
||
// xmlTemplateBundle is the JS source for the compiled XML templates,
|
||
// generated at startup by compileXMLTemplates(). It replaces the
|
||
// pre-compiled build/js/web/static/src/xml_templates_bundle.js that
|
||
// was previously produced by tools/compile_templates.py.
|
||
xmlTemplateBundle string
|
||
|
||
// jsBundle is the concatenated JS bundle built at startup. It contains
|
||
// all JS files (except module_loader.js) plus the XML template bundle,
|
||
// served as a single file to avoid hundreds of individual HTTP requests.
|
||
jsBundle string
|
||
|
||
bus *Bus // Message bus for Discuss long-polling
|
||
}
|
||
|
||
// 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, pool),
|
||
}
|
||
|
||
// Compile XML templates to JS at startup, replacing the Python build step.
|
||
log.Println("odoo: compiling XML templates...")
|
||
s.xmlTemplateBundle = compileXMLTemplates(cfg.FrontendDir)
|
||
|
||
// Concatenate all JS files into a single bundle served at /web/assets/bundle.js.
|
||
// This reduces ~539 individual <script> requests to 1.
|
||
log.Println("odoo: building JS bundle...")
|
||
s.jsBundle = buildJSBundle(cfg, s.xmlTemplateBundle)
|
||
|
||
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/", func(w http.ResponseWriter, r *http.Request) {
|
||
// /odoo/base/static/... → serve static files
|
||
if strings.Contains(r.URL.Path, "/static/") {
|
||
s.handleStatic(w, r)
|
||
return
|
||
}
|
||
s.handleWebClient(w, r)
|
||
})
|
||
|
||
// 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)
|
||
s.mux.HandleFunc("/web/dataset/call_button", s.handleCallKW) // call_button uses same dispatch as call_kw
|
||
s.mux.HandleFunc("/web/dataset/call_button/", s.handleCallKW) // with model/method suffix
|
||
|
||
// 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)
|
||
s.mux.HandleFunc("/web/action/load_breadcrumbs", s.handleLoadBreadcrumbs)
|
||
s.mux.HandleFunc("/web/action/run", s.handleActionRun)
|
||
|
||
// Model definitions
|
||
s.mux.HandleFunc("/web/model/get_definitions", s.handleGetDefinitions)
|
||
|
||
// Database endpoints
|
||
s.mux.HandleFunc("/web/database/list", s.handleDBList)
|
||
|
||
// Database manager (mirrors Python Odoo's /web/database/manager)
|
||
s.mux.HandleFunc("/web/database/manager", s.handleDatabaseManager)
|
||
s.mux.HandleFunc("/web/database/create", s.handleDatabaseCreate)
|
||
|
||
// Image serving (placeholder for uploaded images)
|
||
s.mux.HandleFunc("/web/image", s.handleImage)
|
||
s.mux.HandleFunc("/web/image/", s.handleImage)
|
||
|
||
// Concatenated JS bundle (all modules in one file)
|
||
s.mux.HandleFunc("/web/assets/bundle.js", s.handleJSBundle)
|
||
|
||
// PWA manifest
|
||
s.mux.HandleFunc("/web/manifest.webmanifest", s.handleManifest)
|
||
|
||
// File upload and download
|
||
s.mux.HandleFunc("/web/binary/upload_attachment", s.handleUpload)
|
||
s.mux.HandleFunc("/web/content/", s.handleContent)
|
||
|
||
// CSV export
|
||
s.mux.HandleFunc("/web/export/csv", s.handleExportCSV)
|
||
s.mux.HandleFunc("/web/export/xlsx", s.handleExportXLSX)
|
||
|
||
// Import
|
||
s.mux.HandleFunc("/web/import/csv", s.handleImportCSV)
|
||
|
||
// Post-setup wizard
|
||
s.mux.HandleFunc("/web/setup/wizard", s.handleSetupWizard)
|
||
s.mux.HandleFunc("/web/setup/wizard/save", s.handleSetupWizardSave)
|
||
|
||
// Bank statement import
|
||
s.mux.HandleFunc("/web/bank_statement/import", s.handleBankStatementImport)
|
||
|
||
// Reports (HTML and PDF report rendering)
|
||
s.mux.HandleFunc("/report/", s.handleReport)
|
||
s.mux.HandleFunc("/report/html/", s.handleReport)
|
||
s.mux.HandleFunc("/report/pdf/", s.handleReportPDF)
|
||
|
||
// Logout & Account
|
||
s.mux.HandleFunc("/web/session/logout", s.handleLogout)
|
||
s.mux.HandleFunc("/web/session/account", s.handleSessionAccount)
|
||
s.mux.HandleFunc("/web/session/switch_company", s.handleSwitchCompany)
|
||
|
||
// Health check
|
||
s.mux.HandleFunc("/health", s.handleHealth)
|
||
|
||
// Portal routes (external user access)
|
||
s.registerPortalRoutes()
|
||
s.registerPortalSignupRoutes()
|
||
s.registerBusRoutes()
|
||
|
||
// Static files (catch-all for /<addon>/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: LoggingMiddleware(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 — reject if no session (defense in depth)
|
||
sess := GetSession(r)
|
||
if sess == nil {
|
||
s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: 100, Message: "Session expired"})
|
||
return
|
||
}
|
||
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)
|
||
}
|
||
|
||
// sensitiveFields lists fields that only admin (uid=1) may write to.
|
||
// Prevents privilege escalation via field manipulation.
|
||
var sensitiveFields = map[string]map[string]bool{
|
||
"ir.cron": {"user_id": true, "model_name": true, "method_name": true},
|
||
"ir.model.access": {"group_id": true, "perm_read": true, "perm_write": true, "perm_create": true, "perm_unlink": true},
|
||
"ir.rule": {"domain_force": true, "groups": true, "perm_read": true, "perm_write": true, "perm_create": true, "perm_unlink": true},
|
||
"res.users": {"groups_id": true},
|
||
"res.groups": {"users": true},
|
||
}
|
||
|
||
// checkSensitiveFields blocks non-admin users from writing protected fields.
|
||
func checkSensitiveFields(env *orm.Environment, model string, vals orm.Values) *RPCError {
|
||
if env.UID() == 1 || env.IsSuperuser() {
|
||
return nil
|
||
}
|
||
fields, ok := sensitiveFields[model]
|
||
if !ok {
|
||
return nil
|
||
}
|
||
for field := range vals {
|
||
if fields[field] {
|
||
return &RPCError{
|
||
Code: 403,
|
||
Message: fmt.Sprintf("Access Denied: field %q on %s is admin-only", field, model),
|
||
}
|
||
}
|
||
}
|
||
return 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 {
|
||
// DB error → deny access (fail-closed)
|
||
log.Printf("access: DB error checking ACL for model %s: %v", model, err)
|
||
return &RPCError{
|
||
Code: 403,
|
||
Message: fmt.Sprintf("Access Denied: %s on %s (internal error)", method, model),
|
||
}
|
||
}
|
||
if count == 0 {
|
||
// No ACL rules defined for this model → deny (fail-closed).
|
||
// All models should have ACL seed data via seedACLRules().
|
||
log.Printf("access: no ACL for model %s, denying (fail-closed)", model)
|
||
return &RPCError{
|
||
Code: 403,
|
||
Message: fmt.Sprintf("Access Denied: no ACL rules for %s", model),
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
log.Printf("access: DB error checking ACL grant for model %s: %v", model, err)
|
||
return &RPCError{
|
||
Code: 403,
|
||
Message: fmt.Sprintf("Access Denied: %s on %s (internal error)", method, model),
|
||
}
|
||
}
|
||
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
|
||
}
|
||
|
||
// Handle ir.actions.report RPC calls (e.g., Print button on invoices).
|
||
// Mirrors: odoo/addons/base/models/ir_actions_report.py IrActionsReport.report_action()
|
||
if params.Model == "ir.actions.report" && params.Method == "report_action" {
|
||
if len(params.Args) > 0 {
|
||
reportName, _ := params.Args[0].(string)
|
||
return map[string]interface{}{
|
||
"type": "ir.actions.report",
|
||
"report_name": reportName,
|
||
"report_type": "qweb-html",
|
||
}, nil
|
||
}
|
||
}
|
||
|
||
// If model is "ir.http", handle special routing methods
|
||
if params.Model == "ir.http" {
|
||
switch params.Method {
|
||
case "session_info":
|
||
// Return session info - already handled by session endpoint
|
||
return map[string]interface{}{}, nil
|
||
}
|
||
}
|
||
|
||
rs := env.Model(params.Model)
|
||
|
||
switch params.Method {
|
||
case "has_group":
|
||
// Check if current user belongs to the given group.
|
||
// Mirrors: odoo/orm/models.py BaseModel.user_has_groups()
|
||
groupXMLID := ""
|
||
if len(params.Args) > 0 {
|
||
groupXMLID, _ = params.Args[0].(string)
|
||
}
|
||
if groupXMLID == "" {
|
||
return false, nil
|
||
}
|
||
// Admin always has all groups
|
||
if env.UID() == 1 {
|
||
return true, nil
|
||
}
|
||
// Parse "module.xml_id" format
|
||
parts := strings.SplitN(groupXMLID, ".", 2)
|
||
if len(parts) != 2 {
|
||
return false, nil
|
||
}
|
||
// Query: does user belong to this group?
|
||
var exists bool
|
||
err := env.Tx().QueryRow(env.Ctx(),
|
||
`SELECT EXISTS(
|
||
SELECT 1 FROM res_groups_res_users_rel gur
|
||
JOIN ir_model_data imd ON imd.res_id = gur.res_groups_id AND imd.model = 'res.groups'
|
||
WHERE gur.res_users_id = $1 AND imd.module = $2 AND imd.name = $3
|
||
)`, env.UID(), parts[0], parts[1]).Scan(&exists)
|
||
if err != nil {
|
||
return false, nil
|
||
}
|
||
return exists, nil
|
||
|
||
case "check_access_rights":
|
||
// Check if current user has the given access right on this model.
|
||
// Mirrors: odoo/orm/models.py BaseModel.check_access_rights()
|
||
operation := "read"
|
||
if len(params.Args) > 0 {
|
||
if op, ok := params.Args[0].(string); ok {
|
||
operation = op
|
||
}
|
||
}
|
||
raiseException := true
|
||
if v, ok := params.KW["raise_exception"].(bool); ok {
|
||
raiseException = v
|
||
}
|
||
accessErr := s.checkAccess(env, params.Model, operation)
|
||
if accessErr != nil {
|
||
if raiseException {
|
||
return nil, accessErr
|
||
}
|
||
return false, nil
|
||
}
|
||
return true, nil
|
||
|
||
case "fields_get":
|
||
return fieldsGetForModel(params.Model), nil
|
||
|
||
case "web_read_group", "read_group":
|
||
return s.handleReadGroup(rs, params)
|
||
|
||
case "web_search_read":
|
||
return handleWebSearchRead(env, params.Model, params)
|
||
|
||
case "web_read":
|
||
return handleWebRead(env, params.Model, params)
|
||
|
||
case "web_save":
|
||
// Combined create-or-update used by the Odoo web client.
|
||
// Mirrors: odoo/addons/web/models/models.py web_save()
|
||
ids := parseIDs(params.Args)
|
||
vals := parseValuesAt(params.Args, 1)
|
||
spec, _ := params.KW["specification"].(map[string]interface{})
|
||
|
||
// Field-level access control
|
||
if err := checkSensitiveFields(env, params.Model, vals); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if len(ids) > 0 && ids[0] > 0 {
|
||
// Update existing record(s)
|
||
err := rs.Browse(ids...).Write(vals)
|
||
if err != nil {
|
||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||
}
|
||
} else {
|
||
// Create new record
|
||
created, err := rs.Create(vals)
|
||
if err != nil {
|
||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||
}
|
||
ids = created.IDs()
|
||
}
|
||
|
||
// Return the saved record via web_read format
|
||
readParams := CallKWParams{
|
||
Model: params.Model,
|
||
Method: "web_read",
|
||
Args: []interface{}{ids},
|
||
KW: map[string]interface{}{"specification": spec},
|
||
}
|
||
return handleWebRead(env, params.Model, readParams)
|
||
|
||
case "get_views":
|
||
return handleGetViews(env, params.Model, params)
|
||
|
||
case "onchange":
|
||
// Return default values and run onchange handlers for changed fields.
|
||
// Mirrors: odoo/orm/models.py BaseModel.onchange()
|
||
model := orm.Registry.Get(params.Model)
|
||
defaults := make(orm.Values)
|
||
if model != nil {
|
||
orm.ApplyDefaults(model, defaults)
|
||
// Call model-specific DefaultGet for dynamic defaults (DB lookups etc.)
|
||
if model.DefaultGet != nil {
|
||
for k, v := range model.DefaultGet(env, nil) {
|
||
if _, exists := defaults[k]; !exists {
|
||
defaults[k] = v
|
||
}
|
||
}
|
||
}
|
||
// Run onchange handlers for changed fields
|
||
// args[1] = current record values, args[2] = list of changed field names
|
||
if len(params.Args) >= 3 {
|
||
if vals, ok := params.Args[1].(map[string]interface{}); ok {
|
||
if fieldNames, ok := params.Args[2].([]interface{}); ok {
|
||
var changed []string
|
||
for _, fn := range fieldNames {
|
||
fname, _ := fn.(string)
|
||
changed = append(changed, fname)
|
||
if handler, exists := model.OnchangeHandlers[fname]; exists {
|
||
updates := handler(env, vals)
|
||
for k, v := range updates {
|
||
defaults[k] = v
|
||
}
|
||
}
|
||
}
|
||
// Run computed fields that depend on changed fields
|
||
computed := orm.RunOnchangeComputes(model, env, vals, changed)
|
||
for k, v := range computed {
|
||
defaults[k] = v
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return map[string]interface{}{
|
||
"value": defaults,
|
||
}, nil
|
||
|
||
case "default_get":
|
||
// Return default values for the requested fields.
|
||
// Mirrors: odoo/orm/models.py BaseModel.default_get()
|
||
model := orm.Registry.Get(params.Model)
|
||
defaults := make(orm.Values)
|
||
if model != nil {
|
||
orm.ApplyDefaults(model, defaults)
|
||
// Call model-specific DefaultGet for dynamic defaults (DB lookups etc.)
|
||
if model.DefaultGet != nil {
|
||
for k, v := range model.DefaultGet(env, nil) {
|
||
if _, exists := defaults[k]; !exists {
|
||
defaults[k] = v
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return defaults, 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)
|
||
if err := checkSensitiveFields(env, params.Model, vals); err != nil {
|
||
return nil, err
|
||
}
|
||
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)
|
||
if err := checkSensitiveFields(env, params.Model, vals); err != nil {
|
||
return nil, err
|
||
}
|
||
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":
|
||
domain := parseDomain(params.Args)
|
||
opts := orm.SearchOpts{}
|
||
if v, ok := params.KW["limit"].(float64); ok {
|
||
opts.Limit = int(v)
|
||
}
|
||
if v, ok := params.KW["offset"].(float64); ok {
|
||
opts.Offset = int(v)
|
||
}
|
||
if v, ok := params.KW["order"].(string); ok {
|
||
opts.Order = v
|
||
}
|
||
found, err := rs.Search(domain, opts)
|
||
if err != nil {
|
||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||
}
|
||
return found.IDs(), 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":
|
||
// name_search: search by name, return [[id, "name"], ...]
|
||
// Mirrors: odoo/orm/models.py BaseModel._name_search()
|
||
nameStr := ""
|
||
if len(params.Args) > 0 {
|
||
nameStr, _ = params.Args[0].(string)
|
||
}
|
||
// Also accept name from kwargs
|
||
if nameStr == "" {
|
||
if v, ok := params.KW["name"].(string); ok {
|
||
nameStr = v
|
||
}
|
||
}
|
||
|
||
limit := defaultNameSearchLimit
|
||
if v, ok := params.KW["limit"].(float64); ok {
|
||
limit = int(v)
|
||
}
|
||
|
||
operator := "ilike"
|
||
if v, ok := params.KW["operator"].(string); ok {
|
||
operator = v
|
||
}
|
||
|
||
// Build domain: name condition + additional args domain
|
||
var nodes []orm.DomainNode
|
||
if nameStr != "" {
|
||
nodes = append(nodes, orm.Leaf("name", operator, nameStr))
|
||
}
|
||
// Parse extra domain from kwargs "args"
|
||
if extraDomainRaw, ok := params.KW["args"].([]interface{}); ok && len(extraDomainRaw) > 0 {
|
||
extraDomain := parseDomain([]interface{}{extraDomainRaw})
|
||
for _, n := range extraDomain {
|
||
nodes = append(nodes, n)
|
||
}
|
||
}
|
||
|
||
domain := orm.And(nodes...)
|
||
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})
|
||
}
|
||
if nameResult == nil {
|
||
nameResult = [][]interface{}{}
|
||
}
|
||
return nameResult, nil
|
||
|
||
case "get_formview_action":
|
||
ids := parseIDs(params.Args)
|
||
if len(ids) == 0 {
|
||
return false, nil
|
||
}
|
||
return map[string]interface{}{
|
||
"type": "ir.actions.act_window",
|
||
"res_model": params.Model,
|
||
"res_id": ids[0],
|
||
"view_mode": "form",
|
||
"views": [][]interface{}{{nil, "form"}},
|
||
"target": "current",
|
||
}, nil
|
||
|
||
case "get_formview_id":
|
||
// Return the default form view ID for this model.
|
||
// Mirrors: odoo/orm/models.py BaseModel.get_formview_id()
|
||
var viewID *int64
|
||
err := env.Tx().QueryRow(env.Ctx(),
|
||
`SELECT id FROM ir_ui_view
|
||
WHERE model = $1 AND type = 'form' AND active = true
|
||
ORDER BY priority, id LIMIT 1`,
|
||
params.Model).Scan(&viewID)
|
||
if err != nil || viewID == nil {
|
||
return false, nil
|
||
}
|
||
return *viewID, nil
|
||
|
||
case "action_get":
|
||
// Try registered method first (e.g. res.users has its own action_get).
|
||
// Mirrors: odoo/addons/base/models/res_users.py action_get()
|
||
model := orm.Registry.Get(params.Model)
|
||
if model != nil && model.Methods != nil {
|
||
if method, ok := model.Methods["action_get"]; 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 false, nil
|
||
|
||
case "name_create":
|
||
nameStr := ""
|
||
if len(params.Args) > 0 {
|
||
nameStr, _ = params.Args[0].(string)
|
||
}
|
||
if nameStr == "" {
|
||
return nil, &RPCError{Code: -32000, Message: "name_create requires a name"}
|
||
}
|
||
created, err := rs.Create(orm.Values{"name": nameStr})
|
||
if err != nil {
|
||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||
}
|
||
return []interface{}{created.ID(), nameStr}, nil
|
||
|
||
case "read_progress_bar":
|
||
return s.handleReadProgressBar(rs, params)
|
||
|
||
case "activity_format":
|
||
ids := parseIDs(params.Args)
|
||
if len(ids) == 0 {
|
||
return []interface{}{}, nil
|
||
}
|
||
// Search activities for this model/record
|
||
actRS := env.Model("mail.activity")
|
||
var allActivities []orm.Values
|
||
for _, id := range ids {
|
||
domain := orm.And(
|
||
orm.Leaf("res_model", "=", params.Model),
|
||
orm.Leaf("res_id", "=", id),
|
||
orm.Leaf("done", "=", false),
|
||
)
|
||
found, err := actRS.Search(domain, orm.SearchOpts{Order: "date_deadline"})
|
||
if err != nil || found.IsEmpty() {
|
||
continue
|
||
}
|
||
records, err := found.Read([]string{"id", "res_model", "res_id", "activity_type_id", "summary", "note", "date_deadline", "user_id", "state"})
|
||
if err != nil {
|
||
continue
|
||
}
|
||
allActivities = append(allActivities, records...)
|
||
}
|
||
if allActivities == nil {
|
||
return []interface{}{}, nil
|
||
}
|
||
// Format M2O fields
|
||
actSpec := map[string]interface{}{
|
||
"activity_type_id": map[string]interface{}{},
|
||
"user_id": map[string]interface{}{},
|
||
}
|
||
formatM2OFields(env, "mail.activity", allActivities, actSpec)
|
||
formatDateFields("mail.activity", allActivities)
|
||
normalizeNullFields("mail.activity", allActivities)
|
||
actResult := make([]interface{}, len(allActivities))
|
||
for i, a := range allActivities {
|
||
actResult[i] = a
|
||
}
|
||
return actResult, nil
|
||
|
||
case "action_archive":
|
||
ids := parseIDs(params.Args)
|
||
if len(ids) > 0 {
|
||
rs.Browse(ids...).Write(orm.Values{"active": false})
|
||
}
|
||
return true, nil
|
||
|
||
case "action_unarchive":
|
||
ids := parseIDs(params.Args)
|
||
if len(ids) > 0 {
|
||
rs.Browse(ids...).Write(orm.Values{"active": true})
|
||
}
|
||
return true, nil
|
||
|
||
case "copy":
|
||
ids := parseIDs(params.Args)
|
||
if len(ids) == 0 {
|
||
return nil, &RPCError{Code: -32000, Message: "No record to copy"}
|
||
}
|
||
// Parse optional default overrides from args[1]
|
||
defaults := parseValuesAt(params.Args, 1)
|
||
created, err := rs.Browse(ids[0]).Copy(defaults)
|
||
if err != nil {
|
||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||
}
|
||
return created.ID(), nil
|
||
|
||
case "web_resequence":
|
||
// Resequence records by their IDs (drag&drop reordering).
|
||
// Mirrors: odoo/addons/web/models/models.py web_resequence()
|
||
ids := parseIDs(params.Args)
|
||
if len(ids) == 0 {
|
||
return []orm.Values{}, nil
|
||
}
|
||
|
||
// Parse field_name (default "sequence")
|
||
fieldName := "sequence"
|
||
if v, ok := params.KW["field_name"].(string); ok {
|
||
fieldName = v
|
||
}
|
||
|
||
// Parse offset (default 0)
|
||
offset := 0
|
||
if v, ok := params.KW["offset"].(float64); ok {
|
||
offset = int(v)
|
||
}
|
||
|
||
// Check if field exists on the model
|
||
model := orm.Registry.Get(params.Model)
|
||
if model == nil || model.GetField(fieldName) == nil {
|
||
return []orm.Values{}, nil
|
||
}
|
||
|
||
// Update sequence for each record in order
|
||
for i, id := range ids {
|
||
if err := rs.Browse(id).Write(orm.Values{fieldName: offset + i}); err != nil {
|
||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||
}
|
||
}
|
||
|
||
// Return records via web_read
|
||
spec, _ := params.KW["specification"].(map[string]interface{})
|
||
readParams := CallKWParams{
|
||
Model: params.Model,
|
||
Method: "web_read",
|
||
Args: []interface{}{ids},
|
||
KW: map[string]interface{}{"specification": spec},
|
||
}
|
||
return handleWebRead(env, params.Model, readParams)
|
||
|
||
case "message_post":
|
||
// Post a message on the record's chatter.
|
||
// Mirrors: odoo/addons/mail/models/mail_thread.py message_post()
|
||
ids := parseIDs(params.Args)
|
||
if len(ids) == 0 {
|
||
return false, nil
|
||
}
|
||
|
||
body, _ := params.KW["body"].(string)
|
||
messageType := "comment"
|
||
if v, _ := params.KW["message_type"].(string); v != "" {
|
||
messageType = v
|
||
}
|
||
|
||
// Get author from current user's partner_id
|
||
var authorID int64
|
||
if err := env.Tx().QueryRow(env.Ctx(),
|
||
`SELECT partner_id FROM res_users WHERE id = $1`, env.UID(),
|
||
).Scan(&authorID); err != nil {
|
||
log.Printf("warning: message_post author lookup failed: %v", err)
|
||
}
|
||
|
||
// Create mail.message linked to the current model/record
|
||
var msgID int64
|
||
err := env.Tx().QueryRow(env.Ctx(),
|
||
`INSERT INTO mail_message (model, res_id, body, message_type, author_id, date, create_uid, write_uid, create_date, write_date)
|
||
VALUES ($1, $2, $3, $4, $5, NOW(), $6, $6, NOW(), NOW())
|
||
RETURNING id`,
|
||
params.Model, ids[0], body, messageType, authorID, env.UID(),
|
||
).Scan(&msgID)
|
||
if err != nil {
|
||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||
}
|
||
return msgID, nil
|
||
|
||
case "_message_get_thread":
|
||
// Get messages for a record's chatter.
|
||
// Mirrors: odoo/addons/mail/models/mail_thread.py
|
||
ids := parseIDs(params.Args)
|
||
if len(ids) == 0 {
|
||
return []interface{}{}, nil
|
||
}
|
||
|
||
rows, err := env.Tx().Query(env.Ctx(),
|
||
`SELECT m.id, m.body, m.message_type, m.date,
|
||
m.author_id, COALESCE(p.name, ''),
|
||
COALESCE(m.subject, ''), COALESCE(m.email_from, '')
|
||
FROM mail_message m
|
||
LEFT JOIN res_partner p ON p.id = m.author_id
|
||
WHERE m.model = $1 AND m.res_id = $2
|
||
ORDER BY m.id DESC`,
|
||
params.Model, ids[0],
|
||
)
|
||
if err != nil {
|
||
return nil, &RPCError{Code: -32000, Message: err.Error()}
|
||
}
|
||
defer rows.Close()
|
||
|
||
var messages []map[string]interface{}
|
||
for rows.Next() {
|
||
var id int64
|
||
var body, msgType, subject, emailFrom string
|
||
var date interface{}
|
||
var authorID int64
|
||
var authorName string
|
||
|
||
if scanErr := rows.Scan(&id, &body, &msgType, &date, &authorID, &authorName, &subject, &emailFrom); scanErr != nil {
|
||
continue
|
||
}
|
||
msg := map[string]interface{}{
|
||
"id": id,
|
||
"body": body,
|
||
"message_type": msgType,
|
||
"date": date,
|
||
"subject": subject,
|
||
"email_from": emailFrom,
|
||
}
|
||
if authorID > 0 {
|
||
msg["author_id"] = []interface{}{authorID, authorName}
|
||
} else {
|
||
msg["author_id"] = false
|
||
}
|
||
messages = append(messages, msg)
|
||
}
|
||
if messages == nil {
|
||
messages = []map[string]interface{}{}
|
||
}
|
||
return messages, nil
|
||
|
||
case "read_followers":
|
||
ids := parseIDs(params.Args)
|
||
if len(ids) == 0 {
|
||
return []interface{}{}, nil
|
||
}
|
||
// Search followers for this model/record
|
||
followerRS := env.Model("mail.followers")
|
||
domain := orm.And(
|
||
orm.Leaf("res_model", "=", params.Model),
|
||
orm.Leaf("res_id", "in", ids),
|
||
)
|
||
found, err := followerRS.Search(domain, orm.SearchOpts{Limit: 100})
|
||
if err != nil || found.IsEmpty() {
|
||
return []interface{}{}, nil
|
||
}
|
||
followerRecords, err := found.Read([]string{"id", "res_model", "res_id", "partner_id"})
|
||
if err != nil {
|
||
return []interface{}{}, nil
|
||
}
|
||
followerSpec := map[string]interface{}{"partner_id": map[string]interface{}{}}
|
||
formatM2OFields(env, "mail.followers", followerRecords, followerSpec)
|
||
normalizeNullFields("mail.followers", followerRecords)
|
||
followerResult := make([]interface{}, len(followerRecords))
|
||
for i, r := range followerRecords {
|
||
followerResult[i] = r
|
||
}
|
||
return followerResult, nil
|
||
|
||
case "get_activity_data":
|
||
// Return activity summary data for records.
|
||
// Mirrors: odoo/addons/mail/models/mail_activity_mixin.py
|
||
emptyResult := map[string]interface{}{
|
||
"activity_types": []interface{}{},
|
||
"activity_res_ids": map[string]interface{}{},
|
||
"grouped_activities": map[string]interface{}{},
|
||
}
|
||
|
||
ids := parseIDs(params.Args)
|
||
if len(ids) == 0 {
|
||
return emptyResult, nil
|
||
}
|
||
|
||
// Get activity types
|
||
typeRS := env.Model("mail.activity.type")
|
||
types, err := typeRS.Search(nil, orm.SearchOpts{Order: "sequence, id"})
|
||
if err != nil || types.IsEmpty() {
|
||
return emptyResult, nil
|
||
}
|
||
typeRecords, _ := types.Read([]string{"id", "name"})
|
||
|
||
typeList := make([]interface{}, len(typeRecords))
|
||
for i, t := range typeRecords {
|
||
typeList[i] = t
|
||
}
|
||
|
||
return map[string]interface{}{
|
||
"activity_types": typeList,
|
||
"activity_res_ids": map[string]interface{}{},
|
||
"grouped_activities": map[string]interface{}{},
|
||
}, nil
|
||
|
||
default:
|
||
// Try registered business methods on the model.
|
||
// Mirrors: odoo/service/model.py call_kw() + odoo/addons/web/controllers/dataset.py call_button()
|
||
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()}
|
||
}
|
||
// If the method returns an action dict (map with "type" key),
|
||
// return it directly so the web client can navigate.
|
||
// Mirrors: odoo/addons/web/controllers/dataset.py call_button()
|
||
if actionMap, ok := result.(map[string]interface{}); ok {
|
||
if _, hasType := actionMap["type"]; hasType {
|
||
return actionMap, nil
|
||
}
|
||
}
|
||
// If result is true or nil, return false (meaning "reload current view")
|
||
if result == nil || result == true {
|
||
return false, nil
|
||
}
|
||
return result, nil
|
||
}
|
||
}
|
||
return nil, &RPCError{
|
||
Code: -32601,
|
||
Message: fmt.Sprintf("Method %q not found on %s", params.Method, params.Model),
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Session / Auth Endpoints ---
|
||
|
||
// loginAttemptInfo tracks login attempts for rate limiting.
|
||
type loginAttemptInfo struct {
|
||
Count int
|
||
LastTime time.Time
|
||
}
|
||
|
||
var (
|
||
loginAttempts = make(map[string]loginAttemptInfo)
|
||
loginAttemptsMu sync.Mutex
|
||
)
|
||
|
||
// checkLoginRateLimit returns false if the login is rate-limited (too many attempts).
|
||
func (s *Server) checkLoginRateLimit(login string) bool {
|
||
loginAttemptsMu.Lock()
|
||
defer loginAttemptsMu.Unlock()
|
||
|
||
now := time.Now()
|
||
|
||
// Periodic cleanup: evict stale entries (>15 min old) to prevent unbounded growth
|
||
if len(loginAttempts) > 100 {
|
||
for k, v := range loginAttempts {
|
||
if now.Sub(v.LastTime) > 15*time.Minute {
|
||
delete(loginAttempts, k)
|
||
}
|
||
}
|
||
}
|
||
|
||
info := loginAttempts[login]
|
||
|
||
// Reset after 15 minutes
|
||
if now.Sub(info.LastTime) > 15*time.Minute {
|
||
info = loginAttemptInfo{}
|
||
}
|
||
|
||
// Max 10 attempts per 15 minutes
|
||
if info.Count >= 10 {
|
||
return false // Rate limited
|
||
}
|
||
|
||
info.Count++
|
||
info.LastTime = now
|
||
loginAttempts[login] = info
|
||
return true
|
||
}
|
||
|
||
// resetLoginRateLimit clears the rate limit counter on successful login.
|
||
func (s *Server) resetLoginRateLimit(login string) {
|
||
loginAttemptsMu.Lock()
|
||
defer loginAttemptsMu.Unlock()
|
||
delete(loginAttempts, login)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// Rate limit login attempts
|
||
if !s.checkLoginRateLimit(params.Login) {
|
||
s.writeJSONRPC(w, req.ID, nil, &RPCError{
|
||
Code: 429, Message: "Too many login attempts. Please try again later.",
|
||
})
|
||
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 (bcrypt only — no plaintext fallback)
|
||
if !tools.CheckPassword(hashedPw, params.Password) {
|
||
s.writeJSONRPC(w, req.ID, nil, &RPCError{
|
||
Code: 100, Message: "Access Denied: invalid login or password",
|
||
})
|
||
return
|
||
}
|
||
|
||
// Successful login – reset rate limiter
|
||
s.resetLoginRateLimit(params.Login)
|
||
|
||
// Query allowed companies for the user
|
||
allowedCompanyIDs := []int64{companyID}
|
||
rows, err := s.pool.Query(r.Context(),
|
||
`SELECT DISTINCT c.id FROM res_company c
|
||
WHERE c.active = true
|
||
ORDER BY c.id`)
|
||
if err == nil {
|
||
defer rows.Close()
|
||
var ids []int64
|
||
for rows.Next() {
|
||
var cid int64
|
||
if rows.Scan(&cid) == nil {
|
||
ids = append(ids, cid)
|
||
}
|
||
}
|
||
if len(ids) > 0 {
|
||
allowedCompanyIDs = ids
|
||
}
|
||
}
|
||
|
||
// Create session
|
||
sess := s.sessions.New(uid, companyID, params.Login)
|
||
sess.AllowedCompanyIDs = allowedCompanyIDs
|
||
|
||
// Set session cookie
|
||
http.SetCookie(w, &http.Cookie{
|
||
Name: "session_id",
|
||
Value: sess.ID,
|
||
Path: "/",
|
||
HttpOnly: true,
|
||
Secure: 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) {
|
||
// Try context first, then fall back to cookie lookup
|
||
sess := GetSession(r)
|
||
if sess == nil {
|
||
if cookie, err := r.Cookie("session_id"); err == nil && cookie.Value != "" {
|
||
sess = s.sessions.Get(cookie.Value)
|
||
}
|
||
}
|
||
if sess == nil {
|
||
s.writeJSONRPC(w, nil, nil, &RPCError{
|
||
Code: 100, Message: "Session expired",
|
||
})
|
||
return
|
||
}
|
||
s.writeJSONRPC(w, nil, s.buildSessionInfo(sess), 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)
|
||
}
|
||
|
||
// handleSessionAccount returns a local URL instead of odoo.com.
|
||
func (s *Server) handleSessionAccount(w http.ResponseWriter, r *http.Request) {
|
||
s.writeJSONRPC(w, nil, "/odoo/action-100", nil)
|
||
}
|
||
|
||
// handleLogout destroys the session and redirects to login.
|
||
// Mirrors: odoo/addons/web/controllers/home.py Home.web_logout()
|
||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||
if cookie, err := r.Cookie("session_id"); err == nil && cookie.Value != "" {
|
||
s.sessions.Delete(cookie.Value)
|
||
}
|
||
http.SetCookie(w, &http.Cookie{
|
||
Name: "session_id",
|
||
Value: "",
|
||
Path: "/",
|
||
MaxAge: -1,
|
||
HttpOnly: true,
|
||
Secure: true,
|
||
})
|
||
http.Redirect(w, r, "/web/login", http.StatusFound)
|
||
}
|
||
|
||
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("Cache-Control", "no-store")
|
||
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
|
||
case int32:
|
||
ids[i] = int64(n)
|
||
}
|
||
}
|
||
return ids
|
||
case []int64:
|
||
return v
|
||
case float64:
|
||
return []int64{int64(v)}
|
||
case int64:
|
||
return []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)
|
||
}
|