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>
This commit is contained in:
Marc
2026-04-12 18:41:57 +02:00
parent 2c7c1e6c88
commit 66383adf06
87 changed files with 14696 additions and 654 deletions

View File

@@ -9,6 +9,7 @@ import (
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
@@ -35,6 +36,8 @@ type Server struct {
// 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.
@@ -128,6 +131,17 @@ func (s *Server) registerRoutes() {
// 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)
@@ -137,10 +151,16 @@ func (s *Server) registerRoutes() {
// 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
}
@@ -255,13 +275,14 @@ func (s *Server) handleCallKW(w http.ResponseWriter, r *http.Request) {
return
}
// Extract UID from session, default to 1 (admin) if no session
uid := int64(1)
companyID := int64(1)
if sess := GetSession(r); sess != nil {
uid = sess.UID
companyID = sess.CompanyID
// 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{
@@ -294,6 +315,36 @@ func (s *Server) handleCallKW(w http.ResponseWriter, r *http.Request) {
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 {
@@ -317,8 +368,22 @@ func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCErr
`SELECT COUNT(*) FROM ir_model_access a
JOIN ir_model m ON m.id = a.model_id
WHERE m.model = $1`, model).Scan(&count)
if err != nil || count == 0 {
return nil // No ACLs definedopen access (like Odoo superuser mode)
if err != nil {
// DB errordeny 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
@@ -334,7 +399,11 @@ func (s *Server) checkAccess(env *orm.Environment, model, method string) *RPCErr
AND (a.group_id IS NULL OR gu.res_users_id = $2)
)`, perm), model, env.UID()).Scan(&granted)
if err != nil {
return nil // On error, allow (fail-open for now)
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{
@@ -379,10 +448,57 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
switch params.Method {
case "has_group":
// Always return true for admin user, stub for now
return true, nil
// 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":
@@ -404,6 +520,11 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
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)
@@ -513,6 +634,9 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
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()}
@@ -522,6 +646,9 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
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()}
@@ -645,9 +772,33 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
}, nil
case "get_formview_id":
return false, nil
// 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":
@@ -665,10 +816,48 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
return []interface{}{created.ID(), nameStr}, nil
case "read_progress_bar":
return map[string]interface{}{}, nil
return s.handleReadProgressBar(rs, params)
case "activity_format":
return []interface{}{}, nil
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)
@@ -697,6 +886,199 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
}
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()
@@ -732,6 +1114,58 @@ func (s *Server) dispatchORM(env *orm.Environment, params CallKWParams) (interfa
// --- 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)
@@ -754,6 +1188,14 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
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
@@ -776,16 +1218,40 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
return
}
// Check password (support both bcrypt and plaintext for migration)
if !tools.CheckPassword(hashedPw, params.Password) && hashedPw != params.Password {
// 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{
@@ -793,6 +1259,7 @@ func (s *Server) handleAuthenticate(w http.ResponseWriter, r *http.Request) {
Value: sess.ID,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
@@ -857,6 +1324,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
})
http.Redirect(w, r, "/web/login", http.StatusFound)
}