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:
@@ -1,6 +1,9 @@
|
||||
package orm
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// ComputeFunc is a function that computes field values for a recordset.
|
||||
// Mirrors: @api.depends decorated methods in Odoo.
|
||||
@@ -253,7 +256,7 @@ func RunOnchangeComputes(m *Model, env *Environment, currentVals Values, changed
|
||||
|
||||
computed, err := fn(rs)
|
||||
if err != nil {
|
||||
// Non-fatal: skip failed computes during onchange
|
||||
log.Printf("orm: onchange compute %s.%s failed: %v", m.Name(), fieldName, err)
|
||||
continue
|
||||
}
|
||||
for k, v := range computed {
|
||||
|
||||
@@ -2,6 +2,8 @@ package orm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -152,6 +154,8 @@ func (dc *DomainCompiler) JoinSQL() string {
|
||||
return " " + strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// compileNodes compiles domain nodes in Polish (prefix) notation.
|
||||
// Returns the SQL string and the number of nodes consumed from the domain starting at pos.
|
||||
func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
||||
if pos >= len(domain) {
|
||||
return "TRUE", nil
|
||||
@@ -167,7 +171,8 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
right, err := dc.compileNodes(domain, pos+2)
|
||||
leftSize := nodeSize(domain, pos+1)
|
||||
right, err := dc.compileNodes(domain, pos+1+leftSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -178,7 +183,8 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
right, err := dc.compileNodes(domain, pos+2)
|
||||
leftSize := nodeSize(domain, pos+1)
|
||||
right, err := dc.compileNodes(domain, pos+1+leftSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -196,8 +202,6 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
||||
return dc.compileCondition(n)
|
||||
|
||||
case domainGroup:
|
||||
// domainGroup wraps a sub-domain as a single node.
|
||||
// Compile it recursively as a full domain.
|
||||
subSQL, _, err := dc.compileDomainGroup(Domain(n))
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -208,6 +212,28 @@ func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
||||
return "", fmt.Errorf("unexpected domain node at position %d: %v", pos, node)
|
||||
}
|
||||
|
||||
// nodeSize returns the number of domain nodes consumed by the subtree at pos.
|
||||
// Operators (&, |) consume 1 + left subtree + right subtree.
|
||||
// NOT consumes 1 + inner subtree. Leaf nodes consume 1.
|
||||
func nodeSize(domain Domain, pos int) int {
|
||||
if pos >= len(domain) {
|
||||
return 0
|
||||
}
|
||||
switch n := domain[pos].(type) {
|
||||
case Operator:
|
||||
_ = n
|
||||
switch domain[pos].(Operator) {
|
||||
case OpAnd, OpOr:
|
||||
leftSize := nodeSize(domain, pos+1)
|
||||
rightSize := nodeSize(domain, pos+1+leftSize)
|
||||
return 1 + leftSize + rightSize
|
||||
case OpNot:
|
||||
return 1 + nodeSize(domain, pos+1)
|
||||
}
|
||||
}
|
||||
return 1 // Condition or domainGroup = 1 node
|
||||
}
|
||||
|
||||
// compileDomainGroup compiles a sub-domain that was wrapped via domainGroup.
|
||||
// It reuses the same DomainCompiler (sharing params and joins) so parameter
|
||||
// indices stay consistent with the outer query.
|
||||
@@ -227,14 +253,12 @@ func (dc *DomainCompiler) compileCondition(c Condition) (string, error) {
|
||||
return "", fmt.Errorf("invalid operator: %q", c.Operator)
|
||||
}
|
||||
|
||||
// Handle dot notation (e.g., "partner_id.name")
|
||||
// Handle dot notation (e.g., "partner_id.name", "partner_id.country_id.code")
|
||||
// by generating LEFT JOINs through the M2O relational chain.
|
||||
parts := strings.Split(c.Field, ".")
|
||||
column := parts[0]
|
||||
|
||||
// TODO: Handle JOINs for dot notation paths
|
||||
// For now, only support direct fields
|
||||
if len(parts) > 1 {
|
||||
// Placeholder for JOIN resolution
|
||||
return dc.compileJoinedCondition(parts, c.Operator, c.Value)
|
||||
}
|
||||
|
||||
@@ -285,7 +309,7 @@ func (dc *DomainCompiler) compileJoinedCondition(fieldPath []string, operator st
|
||||
dc.joins = append(dc.joins, joinClause{
|
||||
table: comodel.Table(),
|
||||
alias: alias,
|
||||
on: fmt.Sprintf("%s.%q = %q.\"id\"", currentAlias, f.Column(), alias),
|
||||
on: fmt.Sprintf("%q.%q = %q.\"id\"", currentAlias, f.Column(), alias),
|
||||
})
|
||||
|
||||
currentModel = comodel
|
||||
@@ -293,8 +317,12 @@ func (dc *DomainCompiler) compileJoinedCondition(fieldPath []string, operator st
|
||||
}
|
||||
|
||||
// The last segment is the actual field to filter on
|
||||
leafField := fieldPath[len(fieldPath)-1]
|
||||
qualifiedColumn := fmt.Sprintf("%s.%q", currentAlias, leafField)
|
||||
leafFieldName := fieldPath[len(fieldPath)-1]
|
||||
leafCol := leafFieldName
|
||||
if lf := currentModel.GetField(leafFieldName); lf != nil {
|
||||
leafCol = lf.Column()
|
||||
}
|
||||
qualifiedColumn := fmt.Sprintf("%q.%q", currentAlias, leafCol)
|
||||
|
||||
return dc.compileQualifiedCondition(qualifiedColumn, operator, value)
|
||||
}
|
||||
@@ -528,13 +556,8 @@ func (dc *DomainCompiler) compileAnyOp(column string, value Value, negate bool)
|
||||
// Rebase parameter indices: shift them by the current param count
|
||||
baseIdx := len(dc.params)
|
||||
dc.params = append(dc.params, subParams...)
|
||||
rebased := subWhere
|
||||
// Replace $N with $(N+baseIdx) in the sub-where clause
|
||||
for i := len(subParams); i >= 1; i-- {
|
||||
old := fmt.Sprintf("$%d", i)
|
||||
new := fmt.Sprintf("$%d", i+baseIdx)
|
||||
rebased = strings.ReplaceAll(rebased, old, new)
|
||||
}
|
||||
// Replace $N with $(N+baseIdx) using regex to avoid $1 matching $10
|
||||
rebased := rebaseParams(subWhere, baseIdx)
|
||||
|
||||
// Determine the join condition based on field type
|
||||
var joinCond string
|
||||
@@ -676,3 +699,14 @@ func wrapLikeValue(value Value) Value {
|
||||
}
|
||||
return "%" + s + "%"
|
||||
}
|
||||
|
||||
// rebaseParams shifts $N placeholders in a SQL string by baseIdx.
|
||||
// Uses regex to avoid $1 matching inside $10.
|
||||
var paramRegex = regexp.MustCompile(`\$(\d+)`)
|
||||
|
||||
func rebaseParams(sql string, baseIdx int) string {
|
||||
return paramRegex.ReplaceAllStringFunc(sql, func(match string) string {
|
||||
n, _ := strconv.Atoi(match[1:])
|
||||
return fmt.Sprintf("$%d", n+baseIdx)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,8 +45,9 @@ type Model struct {
|
||||
checkCompany bool // Enforce multi-company record rules
|
||||
|
||||
// Hooks
|
||||
BeforeCreate func(env *Environment, vals Values) error // Called before INSERT
|
||||
DefaultGet func(env *Environment, fields []string) Values // Dynamic defaults (e.g., from DB)
|
||||
BeforeCreate func(env *Environment, vals Values) error // Called before INSERT
|
||||
BeforeWrite func(env *Environment, ids []int64, vals Values) error // Called before UPDATE — for state guards
|
||||
DefaultGet func(env *Environment, fields []string) Values // Dynamic defaults (e.g., from DB)
|
||||
Constraints []ConstraintFunc // Validation constraints
|
||||
Methods map[string]MethodFunc // Named business methods
|
||||
|
||||
@@ -453,3 +454,32 @@ func (m *Model) Many2manyTableSQL() []string {
|
||||
}
|
||||
return stmts
|
||||
}
|
||||
|
||||
// StateGuard returns a BeforeWrite function that prevents modifications on records
|
||||
// in certain states, except for explicitly allowed fields.
|
||||
// Eliminates the duplicated guard pattern across sale.order, purchase.order,
|
||||
// account.move, and stock.picking.
|
||||
func StateGuard(table, stateCondition string, allowedFields []string, errMsg string) func(env *Environment, ids []int64, vals Values) error {
|
||||
allowed := make(map[string]bool, len(allowedFields))
|
||||
for _, f := range allowedFields {
|
||||
allowed[f] = true
|
||||
}
|
||||
return func(env *Environment, ids []int64, vals Values) error {
|
||||
if _, changingState := vals["state"]; changingState {
|
||||
return nil
|
||||
}
|
||||
var count int
|
||||
err := env.Tx().QueryRow(env.Ctx(),
|
||||
fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE id = ANY($1) AND %s`, table, stateCondition), ids,
|
||||
).Scan(&count)
|
||||
if err != nil || count == 0 {
|
||||
return nil
|
||||
}
|
||||
for field := range vals {
|
||||
if !allowed[field] {
|
||||
return fmt.Errorf("%s: %s", table, errMsg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ func (rs *Recordset) ReadGroup(domain Domain, groupby []string, aggregates []str
|
||||
// Build ORDER BY
|
||||
orderSQL := ""
|
||||
if opt.Order != "" {
|
||||
orderSQL = opt.Order
|
||||
orderSQL = sanitizeOrderBy(opt.Order, m)
|
||||
} else if len(gbCols) > 0 {
|
||||
// Default: order by groupby columns
|
||||
var orderParts []string
|
||||
|
||||
@@ -2,6 +2,7 @@ package orm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -265,18 +266,28 @@ func preprocessRelatedWrites(env *Environment, m *Model, ids []int64, vals Value
|
||||
value := vals[fieldName]
|
||||
delete(vals, fieldName) // Remove from vals — no local column
|
||||
|
||||
// Read FK IDs for all records
|
||||
// Read FK IDs for all records in a single query
|
||||
var fkIDs []int64
|
||||
for _, id := range ids {
|
||||
var fkID *int64
|
||||
env.tx.QueryRow(env.ctx,
|
||||
fmt.Sprintf(`SELECT %q FROM %q WHERE id = $1`, fkDef.Column(), m.Table()),
|
||||
id,
|
||||
).Scan(&fkID)
|
||||
if fkID != nil && *fkID > 0 {
|
||||
fkIDs = append(fkIDs, *fkID)
|
||||
rows, err := env.tx.Query(env.ctx,
|
||||
fmt.Sprintf(`SELECT %q FROM %q WHERE id = ANY($1) AND %q IS NOT NULL`,
|
||||
fkDef.Column(), m.Table(), fkDef.Column()),
|
||||
ids,
|
||||
)
|
||||
if err != nil {
|
||||
delete(vals, fieldName)
|
||||
continue
|
||||
}
|
||||
for rows.Next() {
|
||||
var fkID int64
|
||||
if err := rows.Scan(&fkID); err != nil {
|
||||
log.Printf("orm: preprocessRelatedWrites scan error on %s.%s: %v", m.Name(), fieldName, err)
|
||||
continue
|
||||
}
|
||||
if fkID > 0 {
|
||||
fkIDs = append(fkIDs, fkID)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if len(fkIDs) == 0 {
|
||||
continue
|
||||
@@ -315,6 +326,13 @@ func (rs *Recordset) Write(vals Values) error {
|
||||
|
||||
m := rs.model
|
||||
|
||||
// BeforeWrite hook — state guards, locked record checks etc.
|
||||
if m.BeforeWrite != nil {
|
||||
if err := m.BeforeWrite(rs.env, rs.ids, vals); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var setClauses []string
|
||||
var args []interface{}
|
||||
idx := 1
|
||||
@@ -787,7 +805,7 @@ func (rs *Recordset) Search(domain Domain, opts ...SearchOpts) (*Recordset, erro
|
||||
// Build query
|
||||
order := m.order
|
||||
if opt.Order != "" {
|
||||
order = opt.Order
|
||||
order = sanitizeOrderBy(opt.Order, m)
|
||||
}
|
||||
|
||||
joinSQL := compiler.JoinSQL()
|
||||
@@ -1103,6 +1121,72 @@ func toRecordID(v interface{}) (int64, bool) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// sanitizeOrderBy validates an ORDER BY clause to prevent SQL injection.
|
||||
// Only allows: field names (alphanumeric + underscore), ASC/DESC, NULLS FIRST/LAST, commas.
|
||||
// Returns sanitized string or fallback to "id" if invalid.
|
||||
func sanitizeOrderBy(order string, m *Model) string {
|
||||
if order == "" {
|
||||
return "id"
|
||||
}
|
||||
parts := strings.Split(order, ",")
|
||||
var safe []string
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
tokens := strings.Fields(part)
|
||||
if len(tokens) == 0 {
|
||||
continue
|
||||
}
|
||||
// First token must be a valid field name or "table"."field"
|
||||
col := tokens[0]
|
||||
// Strip quotes for validation
|
||||
cleanCol := strings.ReplaceAll(strings.ReplaceAll(col, "\"", ""), "'", "")
|
||||
// Allow dot notation (table.field) but validate each part
|
||||
colParts := strings.Split(cleanCol, ".")
|
||||
valid := true
|
||||
for _, cp := range colParts {
|
||||
if !isValidIdentifier(cp) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
continue // Skip this part entirely
|
||||
}
|
||||
// Remaining tokens must be ASC, DESC, NULLS, FIRST, LAST
|
||||
safePart := col
|
||||
for _, tok := range tokens[1:] {
|
||||
upper := strings.ToUpper(tok)
|
||||
switch upper {
|
||||
case "ASC", "DESC", "NULLS", "FIRST", "LAST":
|
||||
safePart += " " + upper
|
||||
default:
|
||||
// Invalid token — skip
|
||||
}
|
||||
}
|
||||
safe = append(safe, safePart)
|
||||
}
|
||||
if len(safe) == 0 {
|
||||
return "id"
|
||||
}
|
||||
return strings.Join(safe, ", ")
|
||||
}
|
||||
|
||||
// isValidIdentifier checks if a string is a valid SQL identifier (letters, digits, underscore).
|
||||
func isValidIdentifier(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// qualifyOrderBy prefixes unqualified column names with the table name.
|
||||
// "name, id desc" → "\"my_table\".name, \"my_table\".id desc"
|
||||
func qualifyOrderBy(table, order string) string {
|
||||
|
||||
@@ -70,8 +70,9 @@ func ApplyRecordRules(env *Environment, m *Model, domain Domain) Domain {
|
||||
ORDER BY r.id`,
|
||||
m.Name(), env.UID())
|
||||
if err != nil {
|
||||
log.Printf("orm: ir.rule query failed for %s: %v — denying access", m.Name(), err)
|
||||
sp.Rollback(env.ctx)
|
||||
return domain
|
||||
return append(domain, Leaf("id", "=", -1)) // Deny all — no records match id=-1
|
||||
}
|
||||
|
||||
type ruleRow struct {
|
||||
@@ -207,7 +208,8 @@ func CheckRecordRuleAccess(env *Environment, m *Model, ids []int64, perm string)
|
||||
var count int64
|
||||
err := env.tx.QueryRow(env.ctx, query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return nil // Fail open on error
|
||||
log.Printf("orm: record rule check failed for %s: %v", m.Name(), err)
|
||||
return fmt.Errorf("orm: access denied on %s (record rule check failed)", m.Name())
|
||||
}
|
||||
|
||||
if count < int64(len(ids)) {
|
||||
|
||||
Reference in New Issue
Block a user