diff --git a/odoo-server b/odoo-server index 72e8a6b..1300e5a 100755 Binary files a/odoo-server and b/odoo-server differ diff --git a/pkg/server/action.go b/pkg/server/action.go index 5135e1e..574e73f 100644 --- a/pkg/server/action.go +++ b/pkg/server/action.go @@ -241,6 +241,52 @@ func (s *Server) handleActionLoad(w http.ResponseWriter, r *http.Request) { "help": "", "xml_id": "fleet.action_fleet_vehicle", }, + 100: { + "id": 100, + "type": "ir.actions.act_window", + "name": "Settings", + "res_model": "res.company", + "res_id": 1, + "view_mode": "form", + "views": [][]interface{}{{nil, "form"}}, + "search_view_id": false, + "domain": "[]", + "context": "{}", + "target": "current", + "limit": 80, + "help": "", + "xml_id": "base.action_res_company_form", + }, + 101: { + "id": 101, + "type": "ir.actions.act_window", + "name": "Users", + "res_model": "res.users", + "view_mode": "list,form", + "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "search_view_id": false, + "domain": "[]", + "context": "{}", + "target": "current", + "limit": 80, + "help": "", + "xml_id": "base.action_res_users", + }, + 102: { + "id": 102, + "type": "ir.actions.act_window", + "name": "Sequences", + "res_model": "ir.sequence", + "view_mode": "list,form", + "views": [][]interface{}{{nil, "list"}, {nil, "form"}}, + "search_view_id": false, + "domain": "[]", + "context": "{}", + "target": "current", + "limit": 80, + "help": "", + "xml_id": "base.ir_sequence_form", + }, } action, ok := actions[actionID] diff --git a/pkg/server/image.go b/pkg/server/image.go index 0d5d44e..bc0ea2f 100644 --- a/pkg/server/image.go +++ b/pkg/server/image.go @@ -2,16 +2,54 @@ package server import ( "net/http" + "strings" ) -// handleImage serves placeholder images for model records. -// The real Odoo serves actual uploaded images from ir.attachment. -// For now, return a 1x1 transparent PNG placeholder. +// handleImage serves images for model records. +// Mirrors: odoo/addons/web/controllers/binary.py content_image() +// For now, return SVG placeholders that look like real Odoo avatars. func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) { - // 1x1 transparent PNG + field := r.URL.Query().Get("field") + model := r.URL.Query().Get("model") + + // Also parse path-style URLs: /web/image/res.partner/2/avatar_128 + path := strings.TrimPrefix(r.URL.Path, "/web/image/") + if model == "" && path != "" && path != r.URL.Path { + pathParts := strings.Split(path, "/") + if len(pathParts) >= 3 { + model = pathParts[0] + field = pathParts[2] + } else if len(pathParts) >= 1 { + model = pathParts[0] + } + } + + // Company logo: return a styled SVG with company initial + if model == "res.company" && (field == "logo" || field == "logo_web") { + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Write([]byte(` + + Odoo +`)) + return + } + + // User avatar: return a colored circle with initial + if (model == "res.partner" || model == "res.users") && + (field == "avatar_128" || field == "avatar_256" || field == "image_128" || field == "image_256" || strings.HasPrefix(field, "avatar") || strings.HasPrefix(field, "image")) { + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Write([]byte(` + + U +`)) + return + } + + // Default: 1x1 transparent PNG w.Header().Set("Content-Type", "image/png") w.Header().Set("Cache-Control", "public, max-age=3600") - // Minimal valid 1x1 transparent PNG (67 bytes) png := []byte{ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, diff --git a/pkg/server/menus.go b/pkg/server/menus.go index c2e67b8..f640cbe 100644 --- a/pkg/server/menus.go +++ b/pkg/server/menus.go @@ -16,7 +16,7 @@ func (s *Server) handleLoadMenus(w http.ResponseWriter, r *http.Request) { "root": map[string]interface{}{ "id": "root", "name": "root", - "children": []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + "children": []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 100}, "appID": false, "xmlid": "", "actionID": false, @@ -330,6 +330,49 @@ func (s *Server) handleLoadMenus(w http.ResponseWriter, r *http.Request) { "webIconDataMimetype": nil, "backgroundImage": nil, }, + // Settings + "100": map[string]interface{}{ + "id": 100, + "name": "Settings", + "children": []int{101, 102}, + "appID": 100, + "xmlid": "base.menu_administration", + "actionID": 100, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": "fa-cog,#71639e,#FFFFFF", + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + "101": map[string]interface{}{ + "id": 101, + "name": "Users & Companies", + "children": []int{}, + "appID": 100, + "xmlid": "base.menu_users", + "actionID": 101, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": nil, + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, + "102": map[string]interface{}{ + "id": 102, + "name": "Technical", + "children": []int{}, + "appID": 100, + "xmlid": "base.menu_custom", + "actionID": 102, + "actionModel": "ir.actions.act_window", + "actionPath": false, + "webIcon": nil, + "webIconData": nil, + "webIconDataMimetype": nil, + "backgroundImage": nil, + }, } json.NewEncoder(w).Encode(menus) diff --git a/pkg/server/middleware.go b/pkg/server/middleware.go index 6ffd091..41344e7 100644 --- a/pkg/server/middleware.go +++ b/pkg/server/middleware.go @@ -47,6 +47,7 @@ func AuthMiddleware(store *SessionStore, next http.Handler) http.Handler { if path == "/health" || path == "/web/login" || path == "/web/session/authenticate" || + path == "/web/session/logout" || strings.HasPrefix(path, "/web/database/") || path == "/web/webclient/version_info" || strings.Contains(path, "/static/") { diff --git a/pkg/server/server.go b/pkg/server/server.go index 60998e5..984b0ec 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -119,6 +119,9 @@ func (s *Server) registerRoutes() { // File upload s.mux.HandleFunc("/web/binary/upload_attachment", s.handleUpload) + // Logout + s.mux.HandleFunc("/web/session/logout", s.handleLogout) + // Health check s.mux.HandleFunc("/health", s.handleHealth) @@ -796,6 +799,22 @@ func (s *Server) handleVersionInfo(w http.ResponseWriter, r *http.Request) { }, 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, + }) + 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 {