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

@@ -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 {