package server import ( "bufio" "context" "embed" "encoding/json" "fmt" "net/http" "os" "path/filepath" "strings" "time" "odoo-go/pkg/tools" ) //go:embed assets_js.txt var assetsJSFile embed.FS //go:embed assets_css.txt var assetsCSSFile embed.FS //go:embed assets_xml.txt var assetsXMLFile embed.FS var jsFiles []string var cssFiles []string var xmlFiles []string func init() { jsFiles = loadAssetList("assets_js.txt", assetsJSFile) cssFiles = loadAssetList("assets_css.txt", assetsCSSFile) xmlFiles = loadAssetList("assets_xml.txt", assetsXMLFile) } // loadXMLTemplate reads an XML template file from the frontend directory. func loadXMLTemplate(cfg *tools.Config, urlPath string) string { if cfg.FrontendDir == "" { return "" } rel := strings.TrimPrefix(urlPath, "/") fullPath := filepath.Join(cfg.FrontendDir, rel) data, err := os.ReadFile(fullPath) if err == nil { return string(data) } return "" } func loadAssetList(name string, fs embed.FS) []string { data, err := fs.ReadFile(name) if err != nil { return nil } var files []string scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line != "" && !strings.HasPrefix(line, "#") { files = append(files, line) } } return files } // handleWebClient serves the Odoo webclient HTML shell. // Mirrors: odoo/addons/web/controllers/home.py Home.web_client() func (s *Server) handleWebClient(w http.ResponseWriter, r *http.Request) { // Check if database needs initialization // Mirrors: odoo/addons/web/controllers/home.py ensure_db() if s.isSetupNeeded() { http.Redirect(w, r, "/web/database/manager", http.StatusFound) return } // Check if post-setup wizard is needed (first login, company not configured) if s.isPostSetupNeeded() { if sess := GetSession(r); sess != nil { http.Redirect(w, r, "/web/setup/wizard", http.StatusFound) return } } // Check authentication sess := GetSession(r) if sess == nil { // Try cookie directly cookie, err := r.Cookie("session_id") if err != nil || cookie.Value == "" { http.Redirect(w, r, "/web/login", http.StatusFound) return } sess = s.sessions.Get(cookie.Value) if sess == nil { http.Redirect(w, r, "/web/login", http.StatusFound) return } } sessionInfo := s.buildSessionInfo(sess) sessionInfoJSON, _ := json.Marshal(sessionInfo) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") // Build script tags: module_loader must load first (defines odoo.loader), // then the concatenated bundle serves everything else in one request. // We suppress transient missing-dependency errors during loading by // temporarily replacing reportErrors with a no-op, then restore it // after the bundle has loaded. var scriptTags strings.Builder cacheBuster := fmt.Sprintf("?v=%d", time.Now().Unix()) // 1) module_loader.js — must run first to define odoo.define/odoo.loader scriptTags.WriteString(fmt.Sprintf(" \n", cacheBuster)) // 2) Suppress transient reportErrors while the bundle loads scriptTags.WriteString(" \n") // 3) The concatenated JS bundle (all other modules + XML templates) scriptTags.WriteString(fmt.Sprintf(" \n", cacheBuster)) // 4) Restore reportErrors and run a final check for genuine errors scriptTags.WriteString(" \n") // Build link tags for CSS: compiled SCSS bundle + individual CSS files var linkTags strings.Builder // Main compiled SCSS bundle (Bootstrap + Odoo core styles) linkTags.WriteString(fmt.Sprintf(" \n", cacheBuster)) // Additional plain CSS files for _, src := range cssFiles { if strings.HasSuffix(src, ".css") { linkTags.WriteString(fmt.Sprintf(" \n", src, cacheBuster)) } } // XML templates are compiled to JS (registerTemplate calls) and included // in the JS bundle as xml_templates_bundle.js — no inline XML needed. fmt.Fprintf(w, ` Odoo %s %s `, linkTags.String(), sess.CSRFToken, sessionInfoJSON, scriptTags.String()) } // buildSessionInfo constructs the session_info JSON object expected by the webclient. // Mirrors: odoo/addons/web/models/ir_http.py session_info() func (s *Server) buildSessionInfo(sess *Session) map[string]interface{} { // Build allowed_company_ids from session (populated at login) allowedIDs := sess.AllowedCompanyIDs if len(allowedIDs) == 0 { allowedIDs = []int64{sess.CompanyID} } return map[string]interface{}{ "session_id": sess.ID, "uid": sess.UID, "is_system": sess.UID == 1, "is_admin": sess.UID == 1, "is_public": false, "is_internal_user": true, "user_context": map[string]interface{}{ "lang": "en_US", "tz": "UTC", "allowed_company_ids": allowedIDs, }, "db": s.config.DBName, "registry_hash": fmt.Sprintf("odoo-go-%d", time.Now().Unix()), "server_version": "19.0-go", "server_version_info": []interface{}{19, 0, 0, "final", 0, "g"}, "name": sess.Login, "username": sess.Login, "partner_id": sess.UID + 1, // Simplified mapping "partner_display_name": sess.Login, "partner_write_date": "2026-01-01 00:00:00", "quick_login": true, "web.base.url": fmt.Sprintf("http://localhost:%d", s.config.HTTPPort), "active_ids_limit": 20000, "max_file_upload_size": 134217728, "home_action_id": 1, "current_menu": 1, "support_url": "", "notification_type": "email", "display_switch_company_menu": len(allowedIDs) > 1, "test_mode": false, "show_effect": true, "currencies": map[string]interface{}{ "1": map[string]interface{}{ "id": 1, "name": "EUR", "symbol": "€", "position": "after", "digits": []int{69, 2}, }, }, "bundle_params": map[string]interface{}{ "lang": "en_US", "debug": "assets", }, "user_companies": s.buildUserCompanies(sess.CompanyID, allowedIDs), "user_settings": map[string]interface{}{ "id": 1, "user_id": map[string]interface{}{"id": sess.UID, "display_name": sess.Login}, }, "view_info": map[string]interface{}{ "list": map[string]interface{}{"display_name": "List", "icon": "oi oi-view-list", "multi_record": true}, "form": map[string]interface{}{"display_name": "Form", "icon": "fa fa-address-card", "multi_record": false}, "kanban": map[string]interface{}{"display_name": "Kanban", "icon": "oi oi-view-kanban", "multi_record": true}, "graph": map[string]interface{}{"display_name": "Graph", "icon": "fa fa-area-chart", "multi_record": true}, "pivot": map[string]interface{}{"display_name": "Pivot", "icon": "oi oi-view-pivot", "multi_record": true}, "calendar": map[string]interface{}{"display_name": "Calendar", "icon": "fa fa-calendar", "multi_record": true}, "search": map[string]interface{}{"display_name": "Search", "icon": "oi oi-search", "multi_record": true}, }, "groups": map[string]interface{}{ "base.group_allow_export": true, "base.group_user": true, "base.group_system": true, }, } } // handleTranslations returns translations for the requested language. // Mirrors: odoo/addons/web/controllers/webclient.py translations() // // The web client calls this with params like {mods: ["web","base",...], lang: "de_DE"}. // We load translations from ir_translation table and return them in Odoo's format. func (s *Server) handleTranslations(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=3600") // Determine requested language from POST body or session context lang := "en_US" if r.Method == http.MethodPost { var req JSONRPCRequest if err := json.NewDecoder(r.Body).Decode(&req); err == nil { var params struct { Mods []string `json:"mods"` Lang string `json:"lang"` } if err := json.Unmarshal(req.Params, ¶ms); err == nil && params.Lang != "" { lang = params.Lang } } } if q := r.URL.Query().Get("lang"); q != "" { lang = q } // Default lang parameters (English) langParams := map[string]interface{}{ "direction": "ltr", "date_format": "%%m/%%d/%%Y", "time_format": "%%H:%%M:%%S", "grouping": "[3,0]", "decimal_point": ".", "thousands_sep": ",", "week_start": 1, } // Try to load language parameters from res_lang var dateFormat, timeFormat, decimalPoint, thousandsSep, direction string err := s.pool.QueryRow(r.Context(), `SELECT date_format, time_format, decimal_point, thousands_sep, direction FROM res_lang WHERE code = $1 AND active = true`, lang, ).Scan(&dateFormat, &timeFormat, &decimalPoint, &thousandsSep, &direction) if err == nil { // Convert Go-style format markers to Python-style (double-%) for the web client langParams["date_format"] = dateFormat langParams["time_format"] = timeFormat langParams["decimal_point"] = decimalPoint langParams["thousands_sep"] = thousandsSep langParams["direction"] = direction } // Load translations from ir_translation modules := make(map[string]interface{}) multiLang := false // Check if translations exist for this language rows, err := s.pool.Query(r.Context(), `SELECT COALESCE(module, ''), src, value FROM ir_translation WHERE lang = $1 AND value != '' AND value IS NOT NULL ORDER BY module, name`, lang) if err == nil { defer rows.Close() // Group translations by module modMessages := make(map[string][]map[string]string) for rows.Next() { var module, src, value string if err := rows.Scan(&module, &src, &value); err != nil { continue } if module == "" { module = "web" } modMessages[module] = append(modMessages[module], map[string]string{ "id": src, "string": value, }) } for mod, msgs := range modMessages { modules[mod] = map[string]interface{}{ "messages": msgs, } multiLang = true } } // Check if more than one active language exists var langCount int if err := s.pool.QueryRow(r.Context(), `SELECT COUNT(*) FROM res_lang WHERE active = true`).Scan(&langCount); err == nil { if langCount > 1 { multiLang = true } } json.NewEncoder(w).Encode(map[string]interface{}{ "lang": lang, "hash": fmt.Sprintf("odoo-go-%s", lang), "lang_parameters": langParams, "modules": modules, "multi_lang": multiLang, }) } // buildUserCompanies queries company data and builds the user_companies dict // for the session_info response. Mirrors: odoo/addons/web/models/ir_http.py func (s *Server) buildUserCompanies(currentCompanyID int64, allowedIDs []int64) map[string]interface{} { allowedCompanies := make(map[string]interface{}) // Batch query all companies at once rows, err := s.pool.Query(context.Background(), `SELECT id, COALESCE(name, 'Company'), COALESCE(currency_id, 1) FROM res_company WHERE id = ANY($1)`, allowedIDs) if err == nil { defer rows.Close() for rows.Next() { var cid, currencyID int64 var name string if rows.Scan(&cid, &name, ¤cyID) == nil { allowedCompanies[fmt.Sprintf("%d", cid)] = map[string]interface{}{ "id": cid, "name": name, "sequence": 10, "child_ids": []int64{}, "parent_id": false, "currency_id": currencyID, } } } } // Fallback for any IDs not found in DB for _, cid := range allowedIDs { key := fmt.Sprintf("%d", cid) if _, exists := allowedCompanies[key]; !exists { allowedCompanies[key] = map[string]interface{}{ "id": cid, "name": fmt.Sprintf("Company %d", cid), "sequence": 10, "child_ids": []int64{}, "parent_id": false, "currency_id": int64(1), } } } return map[string]interface{}{ "current_company": currentCompanyID, "allowed_companies": allowedCompanies, "disallowed_ancestor_companies": map[string]interface{}{}, } } // handleSwitchCompany switches the active company for the current session. func (s *Server) handleSwitchCompany(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } sess := GetSession(r) if sess == nil { s.writeJSONRPC(w, nil, nil, &RPCError{Code: 100, Message: "Not authenticated"}) 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 { CompanyID int64 `json:"company_id"` } if err := json.Unmarshal(req.Params, ¶ms); err != nil || params.CompanyID == 0 { s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: -32602, Message: "Invalid company_id"}) return } // Validate company is in allowed list allowed := false for _, cid := range sess.AllowedCompanyIDs { if cid == params.CompanyID { allowed = true break } } if !allowed { s.writeJSONRPC(w, req.ID, nil, &RPCError{Code: 403, Message: "Company not in allowed list"}) return } // Update session sess.CompanyID = params.CompanyID // Persist to DB if s.sessions.pool != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() s.sessions.pool.Exec(ctx, `UPDATE sessions SET company_id = $1 WHERE id = $2`, params.CompanyID, sess.ID) } s.writeJSONRPC(w, req.ID, map[string]interface{}{ "company_id": params.CompanyID, "result": true, }, nil) }