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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user