Files
goodie/pkg/server/server.go
Marc 66383adf06 feat: Portal, Email Inbound, Discuss + module improvements
- 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>
2026-04-12 18:41:57 +02:00

1469 lines
41 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, &params); 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, &params); 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)
}