diff --git a/.golangci.yml b/.golangci.yml index f5bd04c5..412581a6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -9,6 +9,8 @@ linters: - staticcheck - unused settings: + staticcheck: + checks: ["-ST1017"] errcheck: # Deferred Close errors are rarely actionable; keep errcheck for real ignored returns. exclude-functions: @@ -27,4 +29,4 @@ run: issues: max-issues-per-linter: 0 - max-same-issues: 0 + max-same-issues: 0 \ No newline at end of file diff --git a/Makefile b/Makefile index 076c2ec1..cef17f90 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,7 @@ standards: check-sql check-logs check-test-layout check-sql: @bash scripts/check_sql_safety.sh + @bash scripts/check_sql_allowlist.sh check-logs: @bash scripts/check_no_stdlog.sh diff --git a/addons/audit/audit_cleanup.go b/addons/audit/audit_cleanup.go index d3755e59..f9689a3f 100644 --- a/addons/audit/audit_cleanup.go +++ b/addons/audit/audit_cleanup.go @@ -18,7 +18,7 @@ func init() { } func purgeAuditLog(ctx context.Context, model string, id int, _ map[string]string) (string, error) { - if model != auditCleanupModel || id <= 0 { + if model != auditCleanupModel || 0 >= id { return "", fmt.Errorf("invalid cleanup record") } if !orm.UserHasGroupXML(ctx, orm.SecurityUID(ctx), "base.group_system") { @@ -85,7 +85,7 @@ func retentionDaysFromRecord(rec map[string]interface{}) (int, error) { return 30, nil case "custom": d, ok := orm.CoerceInt64(rec["retention_days"]) - if !ok || d <= 0 { + if !ok || 0 >= d { return 0, fmt.Errorf("custom retention requires days greater than zero") } if d > 3650 { diff --git a/addons/audit/audit_export.go b/addons/audit/audit_export.go index 96c2634b..93152c97 100644 --- a/addons/audit/audit_export.go +++ b/addons/audit/audit_export.go @@ -90,7 +90,7 @@ func sha256Hex(data []byte) string { } func auditPeriodBounds(rows []auditExportRow) (from, to string) { - if len(rows) == 0 { + if 0 == len(rows) { return "", "" } dates := make([]string, len(rows)) @@ -102,7 +102,7 @@ func auditPeriodBounds(rows []auditExportRow) (from, to string) { } func auditMinMaxID(rows []auditExportRow) (minID, maxID int64) { - if len(rows) == 0 { + if 0 == len(rows) { return 0, 0 } minID = rows[0].ID diff --git a/addons/audit/audit_retention.go b/addons/audit/audit_retention.go index ff30ff56..dae45faa 100644 --- a/addons/audit/audit_retention.go +++ b/addons/audit/audit_retention.go @@ -19,14 +19,14 @@ const ( // RetentionOptions configures one retention pass. type RetentionOptions struct { - RetentionDays int - FilterModel string - DryRun bool - LegalHold bool - DeleteAfterExport bool - Compress bool - BatchSize int - MaxRowsPerRun int + RetentionDays int + FilterModel string + DryRun bool + LegalHold bool + DeleteAfterExport bool + Compress bool + BatchSize int + MaxRowsPerRun int } // RetentionResult aggregates one run. @@ -56,7 +56,7 @@ func runAuditRetentionCron(ctx context.Context, _ map[string]interface{}) error } func runRetentionFromPolicy(ctx context.Context, model string, id int, _ map[string]string) (string, error) { - if model != auditRetentionPolicyModel || id <= 0 { + if model != auditRetentionPolicyModel || 0 >= id { return "", fmt.Errorf("invalid policy record") } if !orm.UserHasGroupXML(ctx, orm.SecurityUID(ctx), "base.group_system") { @@ -107,7 +107,7 @@ func loadRetentionPolicy(ctx context.Context) (map[string]interface{}, error) { if err != nil { return nil, err } - if len(rows) == 0 { + if 0 == len(rows) { return nil, fmt.Errorf("audit retention policy not configured") } return rows[0], nil @@ -115,14 +115,14 @@ func loadRetentionPolicy(ctx context.Context) (map[string]interface{}, error) { func retentionOptionsFromPolicy(policy map[string]interface{}, forceDryRun bool) RetentionOptions { days, _ := orm.CoerceInt64(policy["hot_retention_days"]) - if days <= 0 { + if 0 >= days { if raw := strings.TrimSpace(orm.GetConfigParam(context.Background(), "audit.default_retention_days", "")); raw != "" { if d, ok := orm.CoerceInt64(raw); ok && d > 0 { days = d } } } - if days <= 0 { + if 0 >= days { days = 90 } batch, _ := orm.CoerceInt64(policy["batch_size"]) @@ -175,7 +175,7 @@ func runAuditRetention(ctx context.Context, opts RetentionOptions) (RetentionRes if err != nil { return out, err } - if len(rows) == 0 { + if 0 == len(rows) { break } if err := processAuditBatch(ctx, opts, rows, &out); err != nil { @@ -215,7 +215,7 @@ func processAuditBatch(ctx context.Context, opts RetentionOptions, rows []map[st ids = append(ids, id) } } - if len(exportRows) == 0 { + if 0 == len(exportRows) { return nil } @@ -287,7 +287,7 @@ func processAuditBatch(ctx context.Context, opts RetentionOptions, rows []map[st func unlinkAuditIDs(ctx context.Context, ids []int64) error { for _, id := range ids { - if id <= 0 { + if 0 >= id { continue } if err := orm.Unlink(ctx, auditModel, int(id)); err != nil { diff --git a/addons/audit/models/sys_audit_cleanup.go b/addons/audit/models/sys_audit_cleanup.go index c311c888..64480ea2 100644 --- a/addons/audit/models/sys_audit_cleanup.go +++ b/addons/audit/models/sys_audit_cleanup.go @@ -7,7 +7,7 @@ import ( type SysAuditCleanup struct { sdk.Model `sumeru:"model=sys.audit.cleanup"` - RetentionPreset sdk.String `sumeru:"required,string=Retention,default=30_days,selection=7_days:Older than 7 days,30_days:Older than 30 days,custom:Custom days"` + RetentionPreset sdk.String `sumeru:"required,string=Retention,default=30_days,selection=7_days:Older than 7 days,30_days:Older than 30 days,custom:Custom days"` RetentionDays sdk.Integer `sumeru:"string=Custom days"` FilterModel sdk.String `sumeru:"string=Limit to model"` DryRun sdk.Boolean `sumeru:"string=Dry run only"` diff --git a/addons/audit/models/sys_audit_retention_policy.go b/addons/audit/models/sys_audit_retention_policy.go index 2badd261..b519f0c6 100644 --- a/addons/audit/models/sys_audit_retention_policy.go +++ b/addons/audit/models/sys_audit_retention_policy.go @@ -7,16 +7,16 @@ import ( type SysAuditRetentionPolicy struct { sdk.Model `sumeru:"model=sys.audit.retention.policy"` - Name sdk.String `sumeru:"required,string=Name,default=Audit retention"` - HotRetentionDays sdk.Integer `sumeru:"required,string=Hot retention days,default=90"` - FilterModel sdk.String `sumeru:"string=Limit to model"` - AutoEnabled sdk.Boolean `sumeru:"string=Automatic retention,default=false"` - BatchSize sdk.Integer `sumeru:"string=Batch size,default=500"` - MaxRowsPerRun sdk.Integer `sumeru:"string=Max rows per run,default=5000"` - Compress sdk.Boolean `sumeru:"string=Gzip exports,default=true"` - DeleteAfterExport sdk.Boolean `sumeru:"string=Delete from database after export,default=false"` - LegalHold sdk.Boolean `sumeru:"string=Legal hold no deletes,default=false"` - DryRunNext sdk.Boolean `sumeru:"string=Dry run on next manual run,default=false"` - LastRunAt sdk.DateTime `sumeru:"string=Last run"` - LastRunSummary sdk.Text `sumeru:"string=Last run summary"` + Name sdk.String `sumeru:"required,string=Name,default=Audit retention"` + HotRetentionDays sdk.Integer `sumeru:"required,string=Hot retention days,default=90"` + FilterModel sdk.String `sumeru:"string=Limit to model"` + AutoEnabled sdk.Boolean `sumeru:"string=Automatic retention,default=false"` + BatchSize sdk.Integer `sumeru:"string=Batch size,default=500"` + MaxRowsPerRun sdk.Integer `sumeru:"string=Max rows per run,default=5000"` + Compress sdk.Boolean `sumeru:"string=Gzip exports,default=true"` + DeleteAfterExport sdk.Boolean `sumeru:"string=Delete from database after export,default=false"` + LegalHold sdk.Boolean `sumeru:"string=Legal hold no deletes,default=false"` + DryRunNext sdk.Boolean `sumeru:"string=Dry run on next manual run,default=false"` + LastRunAt sdk.DateTime `sumeru:"string=Last run"` + LastRunSummary sdk.Text `sumeru:"string=Last run summary"` } diff --git a/addons/automation/actions.go b/addons/automation/actions.go index 499039b1..58724422 100644 --- a/addons/automation/actions.go +++ b/addons/automation/actions.go @@ -35,7 +35,7 @@ func actionApplies(ctx context.Context, row map[string]interface{}, ev event.Eve return true } resID, ok := orm.CoerceInt64(ev.Payload["id"]) - if evModel == "" || !ok || resID <= 0 { + if evModel == "" || !ok || 0 >= resID { return false } var domain [][]interface{} @@ -107,7 +107,7 @@ func executeServerAction(ctx context.Context, row map[string]interface{}, ev eve } modelName := strings.TrimSpace(orm.AsString(ev.Payload["model"])) resID, ok := orm.CoerceInt64(ev.Payload["id"]) - if modelName == "" || !ok || resID <= 0 { + if modelName == "" || !ok || 0 >= resID { return nil } if err := rejectUnsafeWrite(modelName, vals); err != nil { @@ -140,14 +140,14 @@ func createActivity(ctx context.Context, spec string, ev event.Event) error { summary = strings.TrimSpace(summary) modelName := strings.TrimSpace(orm.AsString(ev.Payload["model"])) resID, ok := orm.CoerceInt64(ev.Payload["id"]) - if xmlid == "" || modelName == "" || !ok || resID <= 0 { + if xmlid == "" || modelName == "" || !ok || 0 >= resID { return fmt.Errorf("activity action requires type, model, and id") } if _, registered := orm.Registry["mail.activity"]; !registered { return fmt.Errorf("mail.activity is not installed") } typeID, typeModel, err := orm.ResolveXmlId(ctx, xmlid) - if err != nil || typeModel != "mail.activity.type" || typeID <= 0 { + if err != nil || typeModel != "mail.activity.type" || 0 >= typeID { return fmt.Errorf("unknown activity type %q", xmlid) } if summary == "" { diff --git a/addons/automation/safety.go b/addons/automation/safety.go index 27800c45..a780c72c 100644 --- a/addons/automation/safety.go +++ b/addons/automation/safety.go @@ -71,7 +71,7 @@ func deleteActionMatched(model string, id int, actionID int64) bool { } func matchBeforeDelete(ctx context.Context, model string, id int, before map[string]interface{}) { - if orm.DB == nil || id <= 0 || automationSkippedModel(model) { + if orm.DB == nil || 0 >= id || automationSkippedModel(model) { return } if _, ok := orm.Registry["sys.server.action"]; !ok { @@ -121,7 +121,7 @@ func matchBeforeDelete(ctx context.Context, model string, id int, before map[str } func claimRun(ctx context.Context, actionID int64, ev event.Event, model string, resID int64) (bool, error) { - if orm.DB == nil || actionID <= 0 { + if orm.DB == nil || 0 >= actionID { return true, nil } if _, ok := orm.Registry["sys.server.action.log"]; !ok { diff --git a/addons/automation/webhook.go b/addons/automation/webhook.go index ca472c53..629c55f5 100644 --- a/addons/automation/webhook.go +++ b/addons/automation/webhook.go @@ -130,7 +130,7 @@ func resolveWebhookDialIPs(host string) ([]net.IP, error) { } out = append(out, ip) } - if len(out) == 0 { + if 0 == len(out) { return nil, fmt.Errorf("webhook host resolved to no addresses") } return out, nil diff --git a/addons/base/data/core_company_data.xml b/addons/base/data/core_company_data.xml deleted file mode 100644 index 0926756f..00000000 --- a/addons/base/data/core_company_data.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - My Company - contact@example.com - 250 Executive Park Blvd - San Francisco - 94134 - - +1 555 0100 - https://example.com - - - diff --git a/addons/base/manifest.json b/addons/base/manifest.json index 05fa99b5..14ed86f4 100644 --- a/addons/base/manifest.json +++ b/addons/base/manifest.json @@ -6,7 +6,6 @@ "description": "The kernel of Sumeru, providing core identities (Users, Companies, Partners) and system metadata (Views, Rules, Models).", "application": true, "data": [ - "data/core_company_data.xml", "data/partner_property_definition_data.xml", "views/core_company_actions.xml", "views/core_users_actions.xml", diff --git a/addons/base/views/menus.xml b/addons/base/views/menus.xml index 2d8e9269..c3654b38 100644 --- a/addons/base/views/menus.xml +++ b/addons/base/views/menus.xml @@ -25,6 +25,12 @@ + + diff --git a/addons/base/views/platform_actions.xml b/addons/base/views/platform_actions.xml index 094390a5..8378c5ed 100644 --- a/addons/base/views/platform_actions.xml +++ b/addons/base/views/platform_actions.xml @@ -4,5 +4,7 @@ + + diff --git a/addons/calendar/models/calendar_event.go b/addons/calendar/models/calendar_event.go index d624188a..8d73f842 100644 --- a/addons/calendar/models/calendar_event.go +++ b/addons/calendar/models/calendar_event.go @@ -7,14 +7,14 @@ import ( type CalendarEvent struct { sdk.Model `sumeru:"model=calendar.event"` - Name sdk.String `sumeru:"required,string=Meeting Subject"` - Start sdk.DateTime `sumeru:"required,string=Start"` - Stop sdk.DateTime `sumeru:"required,string=Stop"` - Allday sdk.Boolean `sumeru:"string=All Day,default=false"` - PartnerIDs sdk.Many2Many[CorePartner] `sumeru:"string=Attendees,table=calendar_event_partner_rel,left=event_id,right=partner_id"` - UserID sdk.Many2One[CoreUser] `sumeru:"string=Organizer"` - ResModel sdk.String `sumeru:"index,column=model,string=Linked Document Model"` - ResID sdk.Integer `sumeru:"index,string=Linked Document"` - Description sdk.Text `sumeru:"string=Description"` - Active sdk.Boolean `sumeru:"string=Active,default=true"` + Name sdk.String `sumeru:"required,string=Meeting Subject"` + Start sdk.DateTime `sumeru:"required,string=Start"` + Stop sdk.DateTime `sumeru:"required,string=Stop"` + Allday sdk.Boolean `sumeru:"string=All Day,default=false"` + PartnerIDs sdk.Many2Many[CorePartner] `sumeru:"string=Attendees,table=calendar_event_partner_rel,left=event_id,right=partner_id"` + UserID sdk.Many2One[CoreUser] `sumeru:"string=Organizer"` + ResModel sdk.String `sumeru:"index,column=model,string=Linked Document Model"` + ResID sdk.Integer `sumeru:"index,string=Linked Document"` + Description sdk.Text `sumeru:"string=Description"` + Active sdk.Boolean `sumeru:"string=Active,default=true"` } diff --git a/addons/digest/hooks.go b/addons/digest/hooks.go index 85a73fcb..69ff75d1 100644 --- a/addons/digest/hooks.go +++ b/addons/digest/hooks.go @@ -20,9 +20,13 @@ func runDueDigests(ctx context.Context, _ event.Event) error { } return orm.WithElevated(ctx, "digest.hook", func(bypass context.Context) error { rows, err := orm.Search(bypass, "digest.digest", [][]interface{}{{"active", "=", true}}) - if err != nil || len(rows) == 0 { + if err != nil { + return err + } + if 0 == len(rows) { return err } + for _, digestRow := range rows { if err := runDigest(bypass, digestRow); err != nil { applog.Warn(ctx, applog.Event{ @@ -40,7 +44,7 @@ func runDueDigests(ctx context.Context, _ event.Event) error { func runDigest(ctx context.Context, digestRow map[string]interface{}) error { digestID, ok := orm.CoerceInt64(digestRow["id"]) - if !ok || digestID <= 0 { + if !ok || 0 >= digestID { return nil } name := strings.TrimSpace(orm.AsString(digestRow["name"])) @@ -51,7 +55,7 @@ func runDigest(ctx context.Context, digestRow map[string]interface{}) error { if err != nil { return err } - if len(kpiRows) == 0 { + if 0 == len(kpiRows) { return nil } var lines []string @@ -71,7 +75,7 @@ func runDigest(ctx context.Context, digestRow map[string]interface{}) error { } lines = append(lines, fmt.Sprintf("- %s: %.0f", label, value)) } - if len(lines) == 0 { + if 0 == len(lines) { return nil } body := name + "\n" + strings.Join(lines, "\n") diff --git a/addons/i18n/README.md b/addons/i18n/README.md index 5563ad7a..ecd4fb7c 100644 --- a/addons/i18n/README.md +++ b/addons/i18n/README.md @@ -12,7 +12,7 @@ addons//i18n/.po Examples: `i18n/fr_FR.po`, `i18n/es.po`. Language comes from the PO header `Language:` line or from the filename (normalized to Sumeru codes like `fr_FR`). -On **install** or **`-u `**, the server imports all `i18n/*.po` files into `sys.translation` with `module` set to that addon’s technical name. +On **server start**, **install**, or **`-u `**, the server imports `i18n/*.po` from every installed module on `addons_path` (kernel, standard, and custom) into `sys.translation`. `module` is that addon’s technical name. `msgid` is the English source (menu name, field label, button label). `en_US` stays the source language; set `core.user.lang` to the catalog language to see translations. ## CLI diff --git a/addons/im/company_href.go b/addons/im/company_href.go index e92319f6..1bb75766 100644 --- a/addons/im/company_href.go +++ b/addons/im/company_href.go @@ -9,11 +9,11 @@ import ( // CompanySettingsFormHref returns a workspace URL to edit company IM settings when the user may read core.company. func CompanySettingsFormHref(ctx context.Context, companyID int) string { - if companyID <= 0 { + if 0 >= companyID { return "" } uid := orm.SecurityUID(ctx) - if uid <= 0 { + if 0 >= uid { return "" } if err := orm.CheckModelAccess(ctx, uid, "core.company", "read"); err != nil { diff --git a/addons/im/direct.go b/addons/im/direct.go index 20e4ee49..7503aebe 100644 --- a/addons/im/direct.go +++ b/addons/im/direct.go @@ -26,7 +26,7 @@ func SearchInternalUsers(ctx context.Context, uid int, query string, limit int) return nil, err } q := strings.TrimSpace(query) - if limit <= 0 { + if 0 >= limit { if q == "" { limit = directUserDirectoryLimit } else { @@ -72,7 +72,7 @@ func ListDirectConversations(ctx context.Context, uid int, limit int) ([]map[str if err := orm.CheckModelAccess(ctx, uid, MessageModel, "read"); err != nil { return nil, err } - if limit <= 0 || limit > 50 { + if 0 >= limit || limit > 50 { limit = 30 } domain := [][]interface{}{ @@ -102,7 +102,7 @@ func ListDirectConversations(ctx context.Context, uid int, limit int) ([]map[str default: continue } - if peer <= 0 || peer == uid { + if 0 >= peer || peer == uid { continue } if _, ok := seen[peer]; ok { @@ -148,7 +148,7 @@ func directUnreadBySender(ctx context.Context, uid int) map[int]int { } for _, row := range rows { sid, _ := orm.CoerceInt64(row["sender_id"]) - if sid <= 0 { + if 0 >= sid { continue } out[int(sid)]++ @@ -158,7 +158,7 @@ func directUnreadBySender(ctx context.Context, uid int) map[int]int { // ListDirectThread returns messages between uid and peerID oldest first. func ListDirectThread(ctx context.Context, uid, peerID int) ([]map[string]interface{}, error) { - if peerID <= 0 || peerID == uid { + if 0 >= peerID || peerID == uid { return nil, fmt.Errorf("invalid peer") } if orm.DB == nil { @@ -206,7 +206,7 @@ func ListDirectThread(ctx context.Context, uid, peerID int) ([]map[string]interf } func listMessageAttachments(ctx context.Context, uid, messageID int) []map[string]interface{} { - if messageID <= 0 { + if 0 >= messageID { return nil } if err := orm.CheckModelAccess(ctx, uid, "sys.attachment", "read"); err != nil { diff --git a/addons/im/settings.go b/addons/im/settings.go index 447ff483..fffa082e 100644 --- a/addons/im/settings.go +++ b/addons/im/settings.go @@ -14,7 +14,7 @@ func IMEnabled(ctx context.Context) bool { return true } companyID := orm.CompanyIDFromContext(ctx) - if companyID <= 0 { + if 0 >= companyID { companyID = orm.ActiveCompanyIDForUser(ctx, orm.SecurityUID(ctx)) } if enabled, ok := readIMEnabledColumn(ctx, int(companyID)); ok { diff --git a/addons/im/unread.go b/addons/im/unread.go index 4e9e8d1d..817997b3 100644 --- a/addons/im/unread.go +++ b/addons/im/unread.go @@ -8,7 +8,7 @@ import ( // UnreadDirectCount returns incoming messages not yet marked read for uid. func UnreadDirectCount(ctx context.Context, uid int) (int, error) { - if uid <= 0 || orm.DB == nil { + if 0 >= uid || orm.DB == nil { return 0, nil } if err := orm.CheckModelAccess(ctx, uid, MessageModel, "read"); err != nil { @@ -30,7 +30,7 @@ func UnreadDirectCount(ctx context.Context, uid int) (int, error) { // MarkDirectThreadRead sets is_read on incoming messages in a peer thread. func MarkDirectThreadRead(ctx context.Context, uid, peerID int) error { - if uid <= 0 || peerID <= 0 { + if 0 >= uid || 0 >= peerID { return nil } if err := orm.CheckModelAccess(ctx, uid, MessageModel, "write"); err != nil { @@ -46,7 +46,7 @@ func MarkDirectThreadRead(ctx context.Context, uid, peerID int) error { } for _, row := range rows { id, _ := orm.CoerceInt64(row["id"]) - if id <= 0 { + if 0 >= id { continue } if _, err := orm.Update(ctx, MessageModel, [][]interface{}{{"id", "=", int(id)}}, map[string]interface{}{"is_read": true}); err != nil { diff --git a/addons/mail/actions.go b/addons/mail/actions.go index b6e39f76..49cd6db6 100644 --- a/addons/mail/actions.go +++ b/addons/mail/actions.go @@ -16,7 +16,7 @@ func init() { } func actionDone(ctx context.Context, model string, id int, _ map[string]string) (string, error) { - if model != "mail.activity" || id <= 0 { + if model != "mail.activity" || 0 >= id { return "", fmt.Errorf("record not found") } rec, err := orm.SearchOne(ctx, "mail.activity", map[string]interface{}{"id": id}) @@ -36,7 +36,7 @@ func actionDone(ctx context.Context, model string, id int, _ map[string]string) } func actionSnooze(ctx context.Context, model string, id int, _ map[string]string) (string, error) { - if model != "mail.activity" || id <= 0 { + if model != "mail.activity" || 0 >= id { return "", fmt.Errorf("record not found") } rec, err := orm.SearchOne(ctx, "mail.activity", map[string]interface{}{"id": id}) diff --git a/addons/mail/parent_filter.go b/addons/mail/parent_filter.go index cbc8cb12..53d87ebc 100644 --- a/addons/mail/parent_filter.go +++ b/addons/mail/parent_filter.go @@ -19,7 +19,7 @@ func filterParentReadable(ctx context.Context, model string, rows []map[string]i for _, row := range rows { parent := strings.TrimSpace(orm.AsString(row["model"])) parentID := recordID(row["res_id"]) - if parent == "" || parentID <= 0 || parent == "mail.message" || parent == "mail.activity" { + if parent == "" || 0 >= parentID || parent == "mail.message" || parent == "mail.activity" { if model == "mail.message" && strings.TrimSpace(orm.AsString(row["state"])) == "quarantine" && manager { out = append(out, row) } diff --git a/addons/platform/models/platform_pricing_sample.go b/addons/platform/models/platform_pricing_sample.go index df7615f2..edf822de 100644 --- a/addons/platform/models/platform_pricing_sample.go +++ b/addons/platform/models/platform_pricing_sample.go @@ -8,7 +8,7 @@ import ( type PlatformPricingSample struct { sdk.Model `sumeru:"model=platform.pricing.sample"` - Name sdk.String `sumeru:"required,string=Label"` + Name sdk.String `sumeru:"required,string=Label"` CurrencyID sdk.Many2One[CoreCurrency] `sumeru:"required,index,string=Currency"` - Amount sdk.Money `sumeru:"required,string=Amount,currency=currency_id"` + Amount sdk.Money `sumeru:"required,string=Amount,currency=currency_id"` } diff --git a/addons/platform/models/sys_bulk_import.go b/addons/platform/models/sys_bulk_import.go index b8fe1382..c2bfc52f 100644 --- a/addons/platform/models/sys_bulk_import.go +++ b/addons/platform/models/sys_bulk_import.go @@ -7,24 +7,24 @@ import ( type SysBulkImport struct { sdk.Model `sumeru:"model=sys.bulk.import"` - Name sdk.String `sumeru:"required,string=Name"` - TargetModel sdk.String `sumeru:"required,string=Target Model"` - ImportMode sdk.String `sumeru:"string=Import Mode"` - AttachmentID sdk.Many2One[SysAttachment] `sumeru:"string=Staged File"` - SelectedFields sdk.Text `sumeru:"string=Selected Fields"` - CsvHeaders sdk.Text `sumeru:"string=CSV Headers"` - ColumnMapping sdk.Text `sumeru:"string=Column Mapping"` - PreviewJson sdk.Text `sumeru:"string=Preview"` - NextUrl sdk.String `sumeru:"string=Return URL"` - UserID sdk.Many2One[CoreUser] `sumeru:"string=User"` - ActionID sdk.Integer `sumeru:"string=Source Action"` - State sdk.String `sumeru:"string=State"` - Direction sdk.String `sumeru:"string=Direction"` + Name sdk.String `sumeru:"required,string=Name"` + TargetModel sdk.String `sumeru:"required,string=Target Model"` + ImportMode sdk.String `sumeru:"string=Import Mode"` + AttachmentID sdk.Many2One[SysAttachment] `sumeru:"string=Staged File"` + SelectedFields sdk.Text `sumeru:"string=Selected Fields"` + CsvHeaders sdk.Text `sumeru:"string=CSV Headers"` + ColumnMapping sdk.Text `sumeru:"string=Column Mapping"` + PreviewJson sdk.Text `sumeru:"string=Preview"` + NextUrl sdk.String `sumeru:"string=Return URL"` + UserID sdk.Many2One[CoreUser] `sumeru:"string=User"` + ActionID sdk.Integer `sumeru:"string=Source Action"` + State sdk.String `sumeru:"string=State"` + Direction sdk.String `sumeru:"string=Direction"` TemplateID sdk.Many2One[SysImportTemplate] `sumeru:"string=Import Template"` - ErrorJson sdk.Text `sumeru:"string=Errors"` - Progress sdk.Integer `sumeru:"string=Progress"` - TotalRows sdk.Integer `sumeru:"string=Total Rows"` - ResultSummary sdk.String `sumeru:"string=Result Summary"` - ExportFormat sdk.String `sumeru:"string=Export Format"` - ExportDomain sdk.Text `sumeru:"string=Export Domain"` + ErrorJson sdk.Text `sumeru:"string=Errors"` + Progress sdk.Integer `sumeru:"string=Progress"` + TotalRows sdk.Integer `sumeru:"string=Total Rows"` + ResultSummary sdk.String `sumeru:"string=Result Summary"` + ExportFormat sdk.String `sumeru:"string=Export Format"` + ExportDomain sdk.Text `sumeru:"string=Export Domain"` } diff --git a/addons/platform/models/sys_import_template.go b/addons/platform/models/sys_import_template.go index 7e6f1ce3..368d1755 100644 --- a/addons/platform/models/sys_import_template.go +++ b/addons/platform/models/sys_import_template.go @@ -7,12 +7,12 @@ import ( type SysImportTemplate struct { sdk.Model `sumeru:"model=sys.import.template"` - Name sdk.String `sumeru:"required,string=Name"` - TargetModel sdk.String `sumeru:"required,index,string=Target Model"` - ImportMode sdk.String `sumeru:"string=Import Mode"` - SelectedFields sdk.Text `sumeru:"string=Selected Fields"` - ColumnMapping sdk.Text `sumeru:"string=Column Mapping"` - Shared sdk.Boolean `sumeru:"string=Shared"` - UserID sdk.Many2One[CoreUser] `sumeru:"string=User"` + Name sdk.String `sumeru:"required,string=Name"` + TargetModel sdk.String `sumeru:"required,index,string=Target Model"` + ImportMode sdk.String `sumeru:"string=Import Mode"` + SelectedFields sdk.Text `sumeru:"string=Selected Fields"` + ColumnMapping sdk.Text `sumeru:"string=Column Mapping"` + Shared sdk.Boolean `sumeru:"string=Shared"` + UserID sdk.Many2One[CoreUser] `sumeru:"string=User"` CompanyID sdk.Many2One[CoreCompany] `sumeru:"string=Company"` } diff --git a/cmd/sumeru-bp/main.go b/cmd/sumeru-bp/main.go index 7d8ef768..27f0ecd2 100644 --- a/cmd/sumeru-bp/main.go +++ b/cmd/sumeru-bp/main.go @@ -5,69 +5,63 @@ import ( "flag" "fmt" "os" - "path/filepath" "strings" "sumeru/core/module" ) func main() { - namePtr := flag.String("name", "", "technical module name (folder + manifest name)") - bpPtr := flag.String("bp", "", "alias for -name") - - outDir := flag.String("out", "", "parent directory for the new module (default: detected workspace addons/)") + nameFlag := flag.String("name", "", "technical module name (folder and manifest name)") + bpFlag := flag.String("bp", "", "alias for -name") + parentFlag := flag.String("out", "", "parent directory for the new module (default: addons/)") flag.Parse() - name := *namePtr - if name == "" { - name = *bpPtr + moduleName := strings.TrimSpace(*nameFlag) + if moduleName == "" { + moduleName = strings.TrimSpace(*bpFlag) } - - if strings.TrimSpace(name) == "" { + if moduleName == "" { fmt.Fprintln(os.Stderr, "usage: sumeru-bp -name [-out ]") fmt.Fprintln(os.Stderr, "alias: -bp") os.Exit(2) } - if !module.ModuleNamePattern.MatchString(name) { - fmt.Fprintf(os.Stderr, "invalid module name %q (must match %s)\n", name, module.ModuleNamePattern.String()) + if !module.ModuleNamePattern.MatchString(moduleName) { + fmt.Fprintf(os.Stderr, "sumeru-bp: invalid module name %q (must match %s)\n", moduleName, module.ModuleNamePattern.String()) os.Exit(2) } - cwd, err := os.Getwd() + workingDir, err := os.Getwd() if err != nil { - fmt.Fprintln(os.Stderr, err) + fmt.Fprintf(os.Stderr, "sumeru-bp: %v\n", err) os.Exit(1) } - - // Try to find the repo root - repoRoot, err := module.FindRepoRoot(cwd) + repoRoot, err := module.FindRepoRoot(workingDir) if err != nil { fmt.Fprintf(os.Stderr, "sumeru-bp: %v\n", err) os.Exit(1) } - - modPath, err := module.ReadModulePath(repoRoot) + goModule, err := module.ReadModulePath(repoRoot) if err != nil { fmt.Fprintf(os.Stderr, "sumeru-bp: %v\n", err) os.Exit(1) } - // Workspace detection and outDir defaulting - resolvedOut := *outDir - if resolvedOut == "" { - resolvedOut = "addons" + parentDir := strings.TrimSpace(*parentFlag) + if parentDir == "" { + parentDir = "addons" } - - cfg := &ScaffoldConfig{ - Name: name, - HumanTitle: humanTitle(name), - TypeName: typeName(name), - ModelDotted: strings.ReplaceAll(name, "_", "."), - ModelImport: modPath + "/" + filepath.ToSlash(filepath.Join(resolvedOut, name, "models")), - OutDir: resolvedOut, - RepoRoot: repoRoot, - ModPath: modPath, + err = scaffoldModule(moduleScaffold{ + ModuleName: moduleName, + DisplayName: displayName(moduleName), + StructName: goStructName(moduleName), + ModelName: strings.ReplaceAll(moduleName, "_", "."), + ModelsImport: modelsImportPath(goModule, repoRoot, parentDir, moduleName), + ParentDir: parentDir, + RepoRoot: repoRoot, + GoModule: goModule, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "sumeru-bp: %v\n", err) + os.Exit(1) } - - RunScaffold(cfg) } diff --git a/cmd/sumeru-bp/scaffold.go b/cmd/sumeru-bp/scaffold.go index 93b95d82..59d4ba14 100644 --- a/cmd/sumeru-bp/scaffold.go +++ b/cmd/sumeru-bp/scaffold.go @@ -6,107 +6,130 @@ import ( "fmt" "os" "path/filepath" - "sync" "text/template" ) //go:embed templates/*.tmpl var templateFS embed.FS -var ( - templateCache map[string]*template.Template - templateCacheMu sync.Once -) +// moduleScaffold is the data rendered into a new addon's files. +type moduleScaffold struct { + ModuleName string // technical name: folder, package, and manifest name + DisplayName string // human title, for example "Sale Order" + StructName string // Go struct name, for example "SaleOrder" + ModelName string // dotted model name, for example "sale.order" + ModelsImport string // Go import path of the models package + ParentDir string // -out directory, relative to the repo or absolute + RepoRoot string + GoModule string // module path from go.mod +} -type ScaffoldConfig struct { - Name string - HumanTitle string - TypeName string - ModelDotted string - ModelImport string - OutDir string - RepoRoot string - ModPath string +var scaffoldDirectories = []string{ + "models", + "views", + "security", + "static/src/img", + "i18n", } -func RunScaffold(cfg *ScaffoldConfig) { - target := filepath.Join(cfg.RepoRoot, filepath.FromSlash(cfg.OutDir), cfg.Name) - if fi, err := os.Stat(target); err == nil && fi.IsDir() { - fmt.Fprintf(os.Stderr, "sumeru-bp: %q already exists\n", target) - os.Exit(1) - } +var scaffoldFiles = []struct { + template string + relPath string +}{ + {"manifest.json.tmpl", "manifest.json"}, + {"init.go.tmpl", "init.go"}, + {"models.go.tmpl", filepath.Join("models", "models.go")}, + {"security.xml.tmpl", filepath.Join("security", "security.xml")}, + {"sys.access.csv.tmpl", filepath.Join("security", "sys.access.csv")}, + {"actions.xml.tmpl", filepath.Join("views", "actions.xml")}, + {"form_view.xml.tmpl", filepath.Join("views", "form_view.xml")}, + {"list_view.xml.tmpl", filepath.Join("views", "list_view.xml")}, + {"kanban_view.xml.tmpl", filepath.Join("views", "kanban_view.xml")}, + {"menus.xml.tmpl", filepath.Join("views", "menus.xml")}, + {"en_US.po.tmpl", filepath.Join("i18n", "en_US.po")}, +} - dirs := []string{ - "models", - "views", - "security", - "static/src/img", +func scaffoldModule(module moduleScaffold) error { + directory, err := moduleDirectory(module) + if err != nil { + return err } - for _, d := range dirs { - if err := os.MkdirAll(filepath.Join(target, filepath.FromSlash(d)), 0o755); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) + for _, rel := range scaffoldDirectories { + if err := os.MkdirAll(filepath.Join(directory, filepath.FromSlash(rel)), 0o755); err != nil { + return fmt.Errorf("create %s: %w", rel, err) + } + } + templates, err := parseTemplates() + if err != nil { + return err + } + for _, file := range scaffoldFiles { + parsed := templates[file.template] + if parsed == nil { + return fmt.Errorf("template %q is not embedded", file.template) + } + var rendered bytes.Buffer + if err := parsed.Execute(&rendered, module); err != nil { + return fmt.Errorf("render %s: %w", file.template, err) + } + dest := filepath.Join(directory, file.relPath) + if err := writeFile(dest, rendered.String()); err != nil { + return err } } - renderTemplate(cfg, "manifest.json.tmpl", filepath.Join(target, "manifest.json")) - renderTemplate(cfg, "init.go.tmpl", filepath.Join(target, "init.go")) - renderTemplate(cfg, "models.go.tmpl", filepath.Join(target, "models", "models.go")) - renderTemplate(cfg, "security.xml.tmpl", filepath.Join(target, "security", "security.xml")) - renderTemplate(cfg, "sys.access.csv.tmpl", filepath.Join(target, "security", "sys.access.csv")) - renderTemplate(cfg, "actions.xml.tmpl", filepath.Join(target, "views", "actions.xml")) - renderTemplate(cfg, "form_view.xml.tmpl", filepath.Join(target, "views", "form_view.xml")) - renderTemplate(cfg, "list_view.xml.tmpl", filepath.Join(target, "views", "list_view.xml")) - renderTemplate(cfg, "kanban_view.xml.tmpl", filepath.Join(target, "views", "kanban_view.xml")) - renderTemplate(cfg, "menus.xml.tmpl", filepath.Join(target, "views", "menus.xml")) - - fmt.Printf("Successfully created premium modular addon at: %s\n", target) - fmt.Printf("Next steps:\n") - if cfg.ModPath == "sumeru_custom_addons" { + fmt.Printf("Created %s\n", directory) + fmt.Println("Next:") + if module.GoModule == "sumeru_custom_addons" { fmt.Printf(" 1. Run 'make generate' in the workspace\n") - fmt.Printf(" 2. Run 'make install MODULES=%s'\n", cfg.Name) + fmt.Printf(" 2. Run 'make install MODULES=%s'\n", module.ModuleName) } else { - fmt.Printf(" 1. Run 'make generate'\n") - fmt.Printf(" 2. Run 'go run ./cmd/sumeru -- -i %s'\n", cfg.Name) + fmt.Println(" 1. Run 'make generate'") + fmt.Printf(" 2. Run 'go run ./cmd/sumeru -- -i %s'\n", module.ModuleName) } + return nil } -func loadTemplates() { - templateCacheMu.Do(func() { - names, err := templateFS.ReadDir("templates") - if err != nil { - fmt.Fprintf(os.Stderr, "error reading templates: %v\n", err) - os.Exit(1) - } - templateCache = make(map[string]*template.Template, len(names)) - for _, entry := range names { - if entry.IsDir() { - continue - } - name := entry.Name() - t, err := template.ParseFS(templateFS, "templates/"+name) - if err != nil { - fmt.Fprintf(os.Stderr, "error parsing template %s: %v\n", name, err) - os.Exit(1) - } - templateCache[name] = t +// moduleDirectory is the new addon folder. A relative ParentDir is under RepoRoot. +// An empty ParentDir means addons/. An existing path is refused. +func moduleDirectory(module moduleScaffold) (string, error) { + parent := module.ParentDir + if parent == "" { + parent = "addons" + } + if !filepath.IsAbs(parent) { + parent = filepath.Join(module.RepoRoot, filepath.FromSlash(parent)) + } + directory := filepath.Join(parent, module.ModuleName) + info, err := os.Stat(directory) + if err == nil { + if !info.IsDir() { + return "", fmt.Errorf("%q exists and is not a directory", directory) } - }) + return "", fmt.Errorf("%q already exists", directory) + } + if !os.IsNotExist(err) { + return "", err + } + return directory, nil } -func renderTemplate(cfg *ScaffoldConfig, tmplName, outPath string) { - loadTemplates() - t := templateCache[tmplName] - if t == nil { - fmt.Fprintf(os.Stderr, "missing template %s\n", tmplName) - os.Exit(1) +func parseTemplates() (map[string]*template.Template, error) { + entries, err := templateFS.ReadDir("templates") + if err != nil { + return nil, fmt.Errorf("read templates: %w", err) } - - var buf bytes.Buffer - if err := t.Execute(&buf, cfg); err != nil { - fmt.Fprintf(os.Stderr, "error executing template %s: %v\n", tmplName, err) - os.Exit(1) + parsed := make(map[string]*template.Template, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + t, err := template.ParseFS(templateFS, "templates/"+name) + if err != nil { + return nil, fmt.Errorf("parse template %s: %w", name, err) + } + parsed[name] = t } - - writeOrDie(outPath, buf.String()) + return parsed, nil } diff --git a/cmd/sumeru-bp/templates/actions.xml.tmpl b/cmd/sumeru-bp/templates/actions.xml.tmpl index e0c5873e..77fee6a5 100644 --- a/cmd/sumeru-bp/templates/actions.xml.tmpl +++ b/cmd/sumeru-bp/templates/actions.xml.tmpl @@ -2,13 +2,13 @@ - - {{.HumanTitle}} - {{.ModelDotted}} + + {{.DisplayName}} + {{.ModelName}} list,form,kanban

- Create your first {{.HumanTitle}}! + Create your first {{.DisplayName}}!

diff --git a/cmd/sumeru-bp/templates/en_US.po.tmpl b/cmd/sumeru-bp/templates/en_US.po.tmpl new file mode 100644 index 00000000..e1c54d80 --- /dev/null +++ b/cmd/sumeru-bp/templates/en_US.po.tmpl @@ -0,0 +1,34 @@ +# Source catalog. Copy to hi_IN.po (or another language code) and translate msgstr. +msgid "" +msgstr "" +"Language: en_US\n" + +msgid "{{.DisplayName}}" +msgstr "{{.DisplayName}}" +{{if ne .DisplayName "Configuration"}} +msgid "Configuration" +msgstr "Configuration" +{{end}}{{if ne .DisplayName "Confirm"}} +msgid "Confirm" +msgstr "Confirm" +{{end}}{{if ne .DisplayName "Description"}} +msgid "Description" +msgstr "Description" +{{end}}{{if ne .DisplayName "Name"}} +msgid "Name" +msgstr "Name" +{{end}}{{if ne .DisplayName "Active"}} +msgid "Active" +msgstr "Active" +{{end}}{{if ne .DisplayName "Sequence"}} +msgid "Sequence" +msgstr "Sequence" +{{end}}{{if ne .DisplayName "Title..."}} +msgid "Title..." +msgstr "Title..." +{{end}}{{if ne .DisplayName "Internal notes..."}} +msgid "Internal notes..." +msgstr "Internal notes..." +{{end}} +msgid "Create your first {{.DisplayName}}!" +msgstr "Create your first {{.DisplayName}}!" diff --git a/cmd/sumeru-bp/templates/form_view.xml.tmpl b/cmd/sumeru-bp/templates/form_view.xml.tmpl index 8fca7406..f79aa547 100644 --- a/cmd/sumeru-bp/templates/form_view.xml.tmpl +++ b/cmd/sumeru-bp/templates/form_view.xml.tmpl @@ -1,7 +1,7 @@ - +
+ + + diff --git a/core/engine/templates/pages/settings_mail.html b/core/engine/templates/pages/settings_mail.html new file mode 100644 index 00000000..94db2258 --- /dev/null +++ b/core/engine/templates/pages/settings_mail.html @@ -0,0 +1,24 @@ + diff --git a/core/engine/viewinherit/xpath.go b/core/engine/viewinherit/xpath.go index e873e3d6..61613195 100644 --- a/core/engine/viewinherit/xpath.go +++ b/core/engine/viewinherit/xpath.go @@ -45,7 +45,7 @@ func ApplyInheritArch(parentArch, inheritFragment string) (string, error) { arch := parentArch frag := stripDataWrapper(inheritFragment) ops := parseXPaths(frag) - if len(ops) == 0 && strings.TrimSpace(frag) != "" { + if 0 == len(ops) && strings.TrimSpace(frag) != "" { return arch, fmt.Errorf("no blocks found in inherit arch") } for _, op := range ops { @@ -81,7 +81,7 @@ func parseXPathIndex(raw string) (int, error) { return 0, nil } n, err := strconv.Atoi(raw) - if err != nil || n <= 0 { + if err != nil || 0 >= n { return 0, fmt.Errorf("xpath index must be a positive integer, got %q", raw) } return n, nil @@ -154,7 +154,7 @@ func findAllElementSpans(arch string, target xpathTarget) []elementSpan { func resolveElementSpan(arch string, target xpathTarget, expr string) (elementSpan, error) { spans := findAllElementSpans(arch, target) - if len(spans) == 0 { + if 0 == len(spans) { return elementSpan{}, fmt.Errorf("inherit xpath: %s not found", expr) } if target.Index == 0 { @@ -221,12 +221,12 @@ func parseXMLAttrs(openTag string) map[string]string { rest = strings.TrimSuffix(rest, "/") for len(rest) > 0 { eq := strings.IndexByte(rest, '=') - if eq <= 0 { + if 0 >= eq { break } name := strings.TrimSpace(rest[:eq]) valPart := strings.TrimSpace(rest[eq+1:]) - if len(valPart) == 0 { + if 0 == len(valPart) { break } quote := valPart[0] @@ -276,7 +276,7 @@ func findMatchingCloseTag(arch string, openEnd int, tag string) (closeStart int, } absClose := pos + nextClose depth-- - if depth == 0 { + if 0 == depth { return absClose, true } pos = absClose + len(closeNeedle) @@ -359,7 +359,7 @@ func applyAttributes(arch string, target xpathTarget, inner, expr string) (strin } old := arch[span.start:span.openEnd] attrs := parseAttributeOps(inner) - if len(attrs) == 0 { + if 0 == len(attrs) { return arch, fmt.Errorf("inherit xpath: no elements for position=attributes") } next := old diff --git a/core/errcode/codes.go b/core/errcode/codes.go index 5f44b5d4..79ae4122 100644 --- a/core/errcode/codes.go +++ b/core/errcode/codes.go @@ -4,18 +4,18 @@ package errcode // Prefer these string values when emitting applog.Event.Code. // RPC API keeps aliases in sumeru/core/server/api for the same literals. const ( - Unauthorized = "UNAUTHORIZED" - SessionExpired = "SESSION_EXPIRED" - InvalidCredentials = "INVALID_CREDENTIALS" - ValidationError = "VALIDATION_ERROR" - EmailAlreadyExists = "EMAIL_ALREADY_EXISTS" - AccessDenied = "ACCESS_DENIED" - RecordNotFound = "RECORD_NOT_FOUND" - NotFound = "NOT_FOUND" - InternalError = "INTERNAL_ERROR" - SyncPartial = "SYNC_PARTIAL" - XMLLinkFailed = "XML_LINK_FAILED" - CronUpdateFailed = "CRON_UPDATE_FAILED" - CronCommitFailed = "CRON_COMMIT_FAILED" - CronHandlerFailed = "CRON_HANDLER_FAILED" + Unauthorized = "UNAUTHORIZED" + SessionExpired = "SESSION_EXPIRED" + InvalidCredentials = "INVALID_CREDENTIALS" + ValidationError = "VALIDATION_ERROR" + EmailAlreadyExists = "EMAIL_ALREADY_EXISTS" + AccessDenied = "ACCESS_DENIED" + RecordNotFound = "RECORD_NOT_FOUND" + NotFound = "NOT_FOUND" + InternalError = "INTERNAL_ERROR" + SyncPartial = "SYNC_PARTIAL" + XMLLinkFailed = "XML_LINK_FAILED" + CronUpdateFailed = "CRON_UPDATE_FAILED" + CronCommitFailed = "CRON_COMMIT_FAILED" + CronHandlerFailed = "CRON_HANDLER_FAILED" ) diff --git a/core/importgen/models_render.go b/core/importgen/models_render.go index 94852242..c1c4a1d6 100644 --- a/core/importgen/models_render.go +++ b/core/importgen/models_render.go @@ -11,7 +11,7 @@ import ( ) func buildORMModelsGo(module string, types []string) string { - if len(types) == 0 { + if 0 == len(types) { return "" } var b strings.Builder @@ -28,7 +28,7 @@ func buildORMModelsGo(module string, types []string) string { } func buildPackageModelsGo(module string, types []string, packageName string) string { - if len(types) == 0 { + if 0 == len(types) { return "" } if packageName == "" { @@ -62,9 +62,13 @@ func writeAddonModelFiles(addonRoot, module string) error { for _, sub := range []string{"models", "wizard"} { dir := filepath.Join(addonRoot, sub) types, err := scanPackageForModels(dir) - if err != nil || len(types) == 0 { + if err != nil { continue } + if 0 == len(types) { + continue + } + body := buildPackageModelsGo(module, types, packageNameForDir(dir)) dest := filepath.Join(dir, "zmodels.go") if err := os.WriteFile(dest, []byte(body), 0o644); err != nil { @@ -76,9 +80,13 @@ func writeAddonModelFiles(addonRoot, module string) error { func writeORMModelsRegistration(ormDir, ormmodelsDir, module string) error { types, err := scanPackageForModels(ormDir) - if err != nil || len(types) == 0 { + if err != nil { return nil } + if 0 == len(types) { + return nil + } + body := buildORMModelsGo(module, types) if err := os.MkdirAll(ormmodelsDir, 0o755); err != nil { return err @@ -114,7 +122,7 @@ func writeORMZRefs(ormDir string) error { sort.Strings(names) zrefsPath := filepath.Join(ormDir, "zrefs.go") - if len(names) == 0 { + if 0 == len(names) { _ = os.Remove(zrefsPath) return nil } diff --git a/core/importgen/scan.go b/core/importgen/scan.go index 84168143..2ebb98d1 100644 --- a/core/importgen/scan.go +++ b/core/importgen/scan.go @@ -144,9 +144,13 @@ func scanPackageForModels(dir string) ([]string, error) { func packageNameForDir(dir string) string { fset := token.NewFileSet() pkgs, err := parser.ParseDir(fset, dir, isSourceGo, parser.PackageClauseOnly) //nolint:staticcheck // SA1019: see scanPackageModels - if err != nil || len(pkgs) == 0 { + if err != nil { + return "models" + } + if 0 == len(pkgs) { return "models" } + for name := range pkgs { return name } diff --git a/core/importgen/validate_relation_depends.go b/core/importgen/validate_relation_depends.go index 667310ae..56808385 100644 --- a/core/importgen/validate_relation_depends.go +++ b/core/importgen/validate_relation_depends.go @@ -27,7 +27,7 @@ func validateRelationDepends(workspace, sumeruRoot, addonsRoot string, discovere if err != nil { return fmt.Errorf("%s: %w", name, err) } - if len(used) == 0 { + if 0 == len(used) { continue } diff --git a/core/importgen/workspace.go b/core/importgen/workspace.go index e2c50902..46240bc9 100644 --- a/core/importgen/workspace.go +++ b/core/importgen/workspace.go @@ -38,7 +38,7 @@ func writeAddonZRefs(workspaceRoot, sumeruRoot, addonsRoot, addonName, modelsDir } zrefsPath := filepath.Join(modelsDir, "zrefs.go") - if len(used) == 0 { + if 0 == len(used) { _ = os.Remove(zrefsPath) return nil } diff --git a/core/importgen/zrefs_render.go b/core/importgen/zrefs_render.go index 620f3945..e81bced9 100644 --- a/core/importgen/zrefs_render.go +++ b/core/importgen/zrefs_render.go @@ -30,7 +30,7 @@ func importAliasForPath(importPath string) string { } func renderZRefs(refs []exportedRef) string { - if len(refs) == 0 { + if 0 == len(refs) { return "" } @@ -79,4 +79,3 @@ func renderZRefs(refs []exportedRef) string { } return b.String() } - diff --git a/core/importgen/zrefs_scan.go b/core/importgen/zrefs_scan.go index e959f82d..95be1d17 100644 --- a/core/importgen/zrefs_scan.go +++ b/core/importgen/zrefs_scan.go @@ -167,7 +167,7 @@ func collectModelRefs(workspace, sumeruRoot, addonsRoot string, modules []string if err != nil { return nil, fmt.Errorf("scan %s: %w", dir, err) } - if len(refs) == 0 { + if 0 == len(refs) { continue } for i := range refs { @@ -185,7 +185,7 @@ func collectModelRefs(workspace, sumeruRoot, addonsRoot string, modules []string } func filterRefsForUsage(refs []modelRef, used map[string]struct{}) []modelRef { - if len(used) == 0 { + if 0 == len(used) { return nil } byTech := map[string]modelRef{} diff --git a/core/metrics/metrics.go b/core/metrics/metrics.go index 2afc6366..ab9db7cd 100644 --- a/core/metrics/metrics.go +++ b/core/metrics/metrics.go @@ -12,9 +12,9 @@ import ( ) var ( - mu sync.Mutex - counters = map[string]*uint64{} - histSum = map[string]*uint64{} // microseconds + mu sync.Mutex + counters = map[string]*uint64{} + histSum = map[string]*uint64{} // microseconds histCount = map[string]*uint64{} ) diff --git a/core/modelmeta/model_spec.go b/core/modelmeta/model_spec.go index 35d70c52..3dff2beb 100644 --- a/core/modelmeta/model_spec.go +++ b/core/modelmeta/model_spec.go @@ -7,11 +7,11 @@ import ( // ModelSpec describes how a Go struct maps to an ORM model name. type ModelSpec struct { - Name string - Extend bool // true when the struct uses inherit= to extend an existing model - DelegationParent string // set when inherits= names a parent model (_inherits delegation) - CompanyShared bool // company=shared on model tag - MailThread bool // mail_thread on model tag + Name string + Extend bool // true when the struct uses inherit= to extend an existing model + DelegationParent string // set when inherits= names a parent model (_inherits delegation) + CompanyShared bool // company=shared on model tag + MailThread bool // mail_thread on model tag } // ModelSpecFromStruct reads model= or inherit= from an embedded ModelMeta tag. diff --git a/core/modelreg/activate.go b/core/modelreg/activate.go index 6cd8a70f..9b98df57 100644 --- a/core/modelreg/activate.go +++ b/core/modelreg/activate.go @@ -22,7 +22,7 @@ var ( ) func queueRegistration(moduleName string, models []pendingModel) { - if len(models) == 0 { + if 0 == len(models) { return } pendingMu.Lock() @@ -74,7 +74,7 @@ func activateAllLocked(moduleOrder []string) error { } func moduleOrderForActivation(moduleOrder []string) []string { - if len(moduleOrder) == 0 { + if 0 == len(moduleOrder) { names := make([]string, 0, len(pendingByModule)) for name := range pendingByModule { names = append(names, name) diff --git a/core/modelreg/selection.go b/core/modelreg/selection.go index ebc162b3..549e226a 100644 --- a/core/modelreg/selection.go +++ b/core/modelreg/selection.go @@ -9,7 +9,7 @@ var selectionByType = map[string][][]string{} // RegisterSelectionForType records selection options for a Go type. func RegisterSelectionForType(rt reflect.Type, options [][]string) { - if rt == nil || len(options) == 0 { + if rt == nil || 0 == len(options) { return } selectionByType[rt.String()] = options diff --git a/core/modelreg/selection_ast.go b/core/modelreg/selection_ast.go index 24472f49..afd82ed0 100644 --- a/core/modelreg/selection_ast.go +++ b/core/modelreg/selection_ast.go @@ -112,7 +112,7 @@ func scanSelectionConsts(pkgDir, typeName string) [][]string { } raw := values[typeName] - if len(raw) == 0 { + if 0 == len(raw) { return nil } sort.Strings(raw) diff --git a/core/module/addon_template/MODULE_STANDARD.txt b/core/module/addon_template/MODULE_STANDARD.txt index f60f9a26..824c87f3 100644 --- a/core/module/addon_template/MODULE_STANDARD.txt +++ b/core/module/addon_template/MODULE_STANDARD.txt @@ -66,8 +66,8 @@ manifest.json data load order (recommended) Translation catalogs (gettext PO) --------------------------------- Optional per-addon folder: i18n/*.po (e.g. i18n/fr_FR.po, i18n/es.po). - On module install or -u, the server imports these into sys.translation - (module = this addon's technical name). Use cmd/sumeru-i18n for manual + On server start, install, or -u, the server imports these into sys.translation + for every installed module (module = this addon's technical name). Use cmd/sumeru-i18n for manual import-po / export-po. See addons/i18n/README.md. File and XML id naming diff --git a/core/module/auto_install.go b/core/module/auto_install.go index b92adae5..df2d1c58 100644 --- a/core/module/auto_install.go +++ b/core/module/auto_install.go @@ -53,7 +53,7 @@ func AllDependsInstalled(ctx context.Context, moduleName string) (bool, error) { if err != nil { return false, err } - return len(missing) == 0, nil + return 0 == len(missing), nil } // AutoInstallCandidateNames returns uninstalled auto_install modules whose depends are satisfied. diff --git a/core/module/cli.go b/core/module/cli.go index 999965fa..2bc1caee 100644 --- a/core/module/cli.go +++ b/core/module/cli.go @@ -95,7 +95,7 @@ func listInstalledModuleNames(ctx context.Context) ([]string, error) { // expandUpdateModuleNames resolves -u all into installed module names, dedupes, and orders by addon dependency topo. // Installed rows not present on disk (stale sys.module) are skipped for "all" only. func expandUpdateModuleNames(ctx context.Context, parts []string) ([]string, error) { - if len(parts) == 0 { + if 0 == len(parts) { return nil, nil } var installedCache []string @@ -166,7 +166,7 @@ func expandUpdateModuleNames(ctx context.Context, parts []string) ([]string, err } func orderModulesForUpdate(names []string) []string { - if len(names) == 0 { + if 0 == len(names) { return nil } set := make(map[string]struct{}, len(names)) @@ -174,7 +174,7 @@ func orderModulesForUpdate(names []string) []string { set[n] = struct{}{} } topo, err := sortAddonsTopo(DiscoveredAddons) - if err != nil || len(topo) == 0 { + if err != nil || 0 == len(topo) { out := append([]string(nil), names...) sort.Strings(out) return out @@ -198,7 +198,7 @@ func orderModulesForUpdate(names []string) []string { // expandInstallModuleNames resolves -i all into uninstalled module names in dependency order. func expandInstallModuleNames(ctx context.Context, parts []string) ([]string, error) { - if len(parts) == 0 { + if 0 == len(parts) { return nil, nil } var installedSet map[string]struct{} diff --git a/core/module/convention.go b/core/module/convention.go index 6f0a56ea..2752b4ac 100644 --- a/core/module/convention.go +++ b/core/module/convention.go @@ -79,7 +79,7 @@ func addonGoModuleContext(addonPath string) (repositoryRoot, moduleImportPath, r // Each addon is validated against the Go module root that contains it (supports multiple // addon roots, e.g. standard sumeru plus a sibling workspace module). func ValidateDiscoveredAddons(discoveredAddons map[string]*Addon) error { - if len(discoveredAddons) == 0 { + if 0 == len(discoveredAddons) { return nil } if err := ValidateDiscoveredDepends(discoveredAddons); err != nil { diff --git a/core/module/data_sync.go b/core/module/data_sync.go index 0ea97d05..9b703481 100644 --- a/core/module/data_sync.go +++ b/core/module/data_sync.go @@ -41,7 +41,7 @@ func processXMLRecords(ctx context.Context, moduleName string, records []parser. } func RecordsFromActions(actions []parser.Action) []parser.Record { - if len(actions) == 0 { + if 0 == len(actions) { return nil } out := make([]parser.Record, 0, len(actions)) diff --git a/core/module/data_sync_menus.go b/core/module/data_sync_menus.go index 9e5f8b73..f9c5e01c 100644 --- a/core/module/data_sync_menus.go +++ b/core/module/data_sync_menus.go @@ -62,7 +62,7 @@ func syncMenusFromItems(ctx context.Context, moduleName string, menus []parser.M for _, menu := range pending { pid := strings.TrimSpace(menu.ParentID) parentID, err := resolveXMLIDInModule(ctx, moduleName, pid) - if err != nil || parentID == 0 { + if err != nil || 0 == parentID { next = append(next, menu) continue } @@ -76,7 +76,7 @@ func syncMenusFromItems(ctx context.Context, moduleName string, menus []parser.M progress++ } pending = next - if progress == 0 { + if 0 == progress { break } } @@ -115,7 +115,7 @@ func menuRowID(ctx context.Context, moduleName, xmlID string) (int, error) { return 0, err } cid, ok := orm.CoerceInt64(md["core_id"]) - if !ok || cid <= 0 { + if !ok || 0 >= cid { return 0, fmt.Errorf("no core_id") } return int(cid), nil diff --git a/core/module/data_sync_records.go b/core/module/data_sync_records.go index e7d7e2b4..7e8d7ee0 100644 --- a/core/module/data_sync_records.go +++ b/core/module/data_sync_records.go @@ -124,7 +124,7 @@ func syncGenericRegistryRecord(ctx context.Context, moduleName string, xmlRecord func syncRegistryRecordByModel(ctx context.Context, moduleName string, xmlRecord parser.Record, modelInstance orm.Model) { fieldMapStrings := parser.RecordFieldMap(xmlRecord) - if len(fieldMapStrings) == 0 { + if 0 == len(fieldMapStrings) { return } impliedEval := strings.TrimSpace(fieldMapStrings["implied_ids"]) @@ -242,7 +242,7 @@ func isEmptyRef(value string) bool { } func resolveEvalTuple(ctx context.Context, moduleName string, parts []interface{}) interface{} { - if len(parts) == 0 { + if 0 == len(parts) { return parts } if cmd, ok := parts[0].(int64); ok && cmd == 4 && len(parts) >= 2 { diff --git a/core/module/data_sync_views.go b/core/module/data_sync_views.go index ad1cedf7..a201e5e9 100644 --- a/core/module/data_sync_views.go +++ b/core/module/data_sync_views.go @@ -196,7 +196,7 @@ func applySysUIViewInherit(ctx context.Context, moduleName string, xmlRecord par return fmt.Errorf("arch missing on inherit record %q", xmlRecord.ID) } parentID, err := resolveXMLIDInModule(ctx, moduleName, inheritReference) - if err != nil || parentID == 0 { + if err != nil || 0 == parentID { return fmt.Errorf("resolve inherit_id %q: %w", inheritReference, err) } parentView, err := orm.SearchOne(ctx, "sys.view", map[string]interface{}{"id": parentID}) @@ -233,7 +233,7 @@ func applySysReportActionInherit(ctx context.Context, moduleName string, xmlReco return fmt.Errorf("arch missing on inherit record %q", xmlRecord.ID) } parentID, err := resolveXMLIDInModule(ctx, moduleName, inheritReference) - if err != nil || parentID == 0 { + if err != nil || 0 == parentID { return fmt.Errorf("resolve inherit_id %q: %w", inheritReference, err) } parent, err := orm.SearchOne(ctx, "sys.report.action", map[string]interface{}{"id": parentID}) diff --git a/core/module/db_sync.go b/core/module/db_sync.go index 5b829ddb..b9b4124f 100644 --- a/core/module/db_sync.go +++ b/core/module/db_sync.go @@ -51,7 +51,7 @@ func syncSysModuleRows(ctx context.Context, discovered map[string]*Addon) error if err != nil { return err } - isBootstrap := totalModules == 0 + isBootstrap := 0 == totalModules for _, addon := range discovered { categoryID, err := resolveModuleCategoryID(ctx, addon.Manifest.Category) diff --git a/core/module/dependency.go b/core/module/dependency.go index 537b190a..0a3bd018 100644 --- a/core/module/dependency.go +++ b/core/module/dependency.go @@ -56,7 +56,7 @@ func sortAddonsTopo(discovered map[string]*Addon) ([]*Addon, error) { addonCandidates = append(addonCandidates, name) } } - if len(addonCandidates) == 0 { + if 0 == len(addonCandidates) { keys := make([]string, 0, len(remainingAddons)) for k := range remainingAddons { keys = append(keys, k) @@ -83,7 +83,7 @@ func containsAddonName(addonList []*Addon, addonName string) bool { // ValidateDiscoveredDepends ensures every manifest depends entry exists on addons_path. func ValidateDiscoveredDepends(discovered map[string]*Addon) error { - if len(discovered) == 0 { + if 0 == len(discovered) { return nil } names := make([]string, 0, len(discovered)) @@ -184,12 +184,12 @@ func OrderedManifestDepends(discovered map[string]*Addon, moduleName string) []s } needed[depName] = struct{}{} } - if len(needed) == 0 { + if 0 == len(needed) { return nil } topo, err := sortAddonsTopo(discovered) - if err != nil || len(topo) == 0 { + if err != nil || 0 == len(topo) { names := make([]string, 0, len(needed)) for n := range needed { names = append(names, n) diff --git a/core/module/discovery.go b/core/module/discovery.go index 08c7a549..655eb4c3 100644 --- a/core/module/discovery.go +++ b/core/module/discovery.go @@ -24,12 +24,12 @@ func LoadAddonPaths(rootPaths []string) error { sanitizedRoots = append(sanitizedRoots, strings.TrimSpace(path)) } } - if len(sanitizedRoots) == 0 { + if 0 == len(sanitizedRoots) { return fmt.Errorf("no addon roots configured") } discoveredAddons := DiscoveredAddons - if len(discoveredAddons) == 0 { + if 0 == len(discoveredAddons) { var err error discoveredAddons, err = DiscoverAddonRoots(sanitizedRoots) if err != nil { diff --git a/core/module/field_access_sync.go b/core/module/field_access_sync.go index c8d77703..eed0219a 100644 --- a/core/module/field_access_sync.go +++ b/core/module/field_access_sync.go @@ -75,11 +75,11 @@ func (addon *Addon) syncCSVFieldAccess(ctx context.Context) error { } accessValues := map[string]interface{}{ - "name": accessName, - "model": modelName, - "field_name": fieldName, - "perm_read": permRead, - "perm_write": permWrite, + "name": accessName, + "model": modelName, + "field_name": fieldName, + "perm_read": permRead, + "perm_write": permWrite, } if groupId > 0 { accessValues["group_id"] = groupId diff --git a/core/module/group_implied_sync.go b/core/module/group_implied_sync.go index 3db6033b..9e65d50b 100644 --- a/core/module/group_implied_sync.go +++ b/core/module/group_implied_sync.go @@ -34,11 +34,11 @@ func ExtractImpliedGroupXMLRefs(evalStr string) []string { // syncCoreGroupImpliedFromEval parses implied_ids eval and inserts rows into core.group.implied. func syncCoreGroupImpliedFromEval(ctx context.Context, moduleName string, groupID int, evalStr string) error { - if groupID <= 0 || strings.TrimSpace(evalStr) == "" { + if 0 >= groupID || strings.TrimSpace(evalStr) == "" { return nil } refs := ExtractImpliedGroupXMLRefs(evalStr) - if len(refs) == 0 { + if 0 == len(refs) { return fmt.Errorf("no (4, ref('…')) implied link commands in %q", evalStr) } implTbl := orm.MustQuotedTableName("core.group.implied") @@ -47,7 +47,7 @@ func syncCoreGroupImpliedFromEval(ctx context.Context, moduleName string, groupI if err != nil { return fmt.Errorf("resolve implied ref %q: %w", ref, err) } - if impliedID <= 0 { + if 0 >= impliedID { return fmt.Errorf("resolve implied ref %q: id is 0", ref) } if impliedID == groupID { @@ -65,11 +65,11 @@ func syncCoreGroupImpliedFromEval(ctx context.Context, moduleName string, groupI // syncSysRuleGroupsFromEval parses groups eval [(4, ref('…'))] into sys.rule.group.rel. func syncSysRuleGroupsFromEval(ctx context.Context, moduleName string, ruleID int, evalStr string) error { - if ruleID <= 0 || strings.TrimSpace(evalStr) == "" { + if 0 >= ruleID || strings.TrimSpace(evalStr) == "" { return nil } refs := ExtractImpliedGroupXMLRefs(evalStr) - if len(refs) == 0 { + if 0 == len(refs) { return fmt.Errorf("no (4, ref('…')) group link commands in %q", evalStr) } relTbl := orm.MustQuotedTableName("sys.rule.group.rel") @@ -81,7 +81,7 @@ func syncSysRuleGroupsFromEval(ctx context.Context, moduleName string, ruleID in if err != nil { return fmt.Errorf("resolve rule group ref %q: %w", ref, err) } - if gid <= 0 { + if 0 >= gid { continue } _, err = orm.DB.ExecContext(ctx, @@ -97,7 +97,7 @@ func syncSysRuleGroupsFromEval(ctx context.Context, moduleName string, ruleID in // EnsureSystemImpliesManagerGroup links base.group_system → module Manager groups // so Settings admins inherit every installed app Manager (and thus User) ladder. func EnsureSystemImpliesManagerGroup(ctx context.Context, moduleName, recordXMLID string, groupID int) error { - if groupID <= 0 { + if 0 >= groupID { return nil } idLower := strings.ToLower(strings.TrimSpace(recordXMLID)) @@ -105,7 +105,7 @@ func EnsureSystemImpliesManagerGroup(ctx context.Context, moduleName, recordXMLI return nil } sysGID, _, err := orm.ResolveXmlId(ctx, "base.group_system") - if err != nil || sysGID <= 0 { + if err != nil || 0 >= sysGID { return err } if sysGID == groupID { diff --git a/core/module/i18n_sync.go b/core/module/i18n_sync.go index 6475489b..53f89ce6 100644 --- a/core/module/i18n_sync.go +++ b/core/module/i18n_sync.go @@ -5,11 +5,43 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "sumeru/core/orm" ) +// SyncInstalledAddonPOFiles imports i18n/*.po for every installed addon on addons_path. +// Kernel, standard, and custom modules use the same folder. i18n must be installed first +// because it owns sys.translation. +func SyncInstalledAddonPOFiles(ctx context.Context) error { + if orm.DB == nil { + return nil + } + installed, err := orm.InstalledModuleNames(ctx) + if err != nil { + return err + } + if _, ok := installed["i18n"]; !ok { + return nil + } + names := make([]string, 0, len(installed)) + for name := range installed { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + addon, ok := DiscoveredAddons[name] + if !ok { + continue + } + if err := SyncAddonTranslationPOFiles(ctx, addon.Path, name); err != nil { + return fmt.Errorf("%s: %w", name, err) + } + } + return nil +} + // SyncAddonTranslationPOFiles imports gettext PO files from addonPath/i18n/*.po. func SyncAddonTranslationPOFiles(ctx context.Context, addonPath, moduleName string) error { if orm.DB == nil { @@ -20,11 +52,16 @@ func SyncAddonTranslationPOFiles(ctx context.Context, addonPath, moduleName stri if err != nil { return err } - if len(matches) == 0 { + if 0 == len(matches) { return nil } + // sys.translation is owned by i18n, which auto-installs after base. PO import + // runs during base install, so the table and (lang, src, module) unique index + // must exist before the upsert. + if err := orm.SyncRegistrySchemaForNames([]string{"sys.translation"}); err != nil { + return err + } var firstErr error - importedTotal := 0 for _, poPath := range matches { f, err := os.Open(poPath) if err != nil { @@ -45,18 +82,11 @@ func SyncAddonTranslationPOFiles(ctx context.Context, addonPath, moduleName stri if lang == "" { lang = orm.LangCodeFromPOFilename(poPath) } - n, err := orm.ImportTranslationsPO(ctx, orm.DB, moduleName, lang, entries) - if err != nil { + if _, err := orm.ImportTranslationsPO(ctx, orm.DB, moduleName, lang, entries); err != nil { if firstErr == nil { firstErr = fmt.Errorf("%s: %w", filepath.Base(poPath), err) } - continue } - importedTotal += n } - if firstErr != nil { - return firstErr - } - _ = importedTotal - return nil + return firstErr } diff --git a/core/module/install.go b/core/module/install.go index 57c23126..ce0f38f0 100644 --- a/core/module/install.go +++ b/core/module/install.go @@ -368,7 +368,7 @@ func transitiveDependencies(moduleName string) []string { delete(needed, moduleName) topo, err := sortAddonsTopo(DiscoveredAddons) - if err != nil || len(topo) == 0 { + if err != nil || 0 == len(topo) { names := make([]string, 0, len(needed)) for n := range needed { names = append(names, n) diff --git a/core/module/sync_errors.go b/core/module/sync_errors.go index 02acb014..359127c5 100644 --- a/core/module/sync_errors.go +++ b/core/module/sync_errors.go @@ -59,7 +59,7 @@ func IsFatalSync(err error) bool { } func aggregateErrors(module string, errs []error) error { - if len(errs) == 0 { + if 0 == len(errs) { return nil } fatal := false @@ -73,7 +73,7 @@ func aggregateErrors(module string, errs []error) error { } msgs = append(msgs, e.Error()) } - if len(msgs) == 0 { + if 0 == len(msgs) { return nil } joined := strings.Join(msgs, "; ") diff --git a/core/module/types.go b/core/module/types.go index 95316616..d0b72e56 100644 --- a/core/module/types.go +++ b/core/module/types.go @@ -13,15 +13,15 @@ type Manifest struct { Depends []string `json:"depends"` Author string `json:"author"` Description string `json:"description"` - Summary string `json:"summary"` // short Apps tagline; falls back to description - Category string `json:"category"` // matches sys.module.category name - Icon string `json:"icon"` // optional relative path under addon, e.g. static/icon.png - Data []string `json:"data"` // XML files to load - Application *bool `json:"application"` // nil = true (show in Apps) - AutoImport *bool `json:"auto_import"` // nil = true; false = omit from generated zimports blank imports - AutoInstall *bool `json:"auto_install"` // nil = false; true = install when all depends are installed - SwcEntry string `json:"swc_entry"` // optional SWC addon entry module URL - Assets []string `json:"assets"` // optional CSS/JS paths relative to addon root + Summary string `json:"summary"` // short Apps tagline; falls back to description + Category string `json:"category"` // matches sys.module.category name + Icon string `json:"icon"` // optional relative path under addon, e.g. static/icon.png + Data []string `json:"data"` // XML files to load + Application *bool `json:"application"` // nil = true (show in Apps) + AutoImport *bool `json:"auto_import"` // nil = true; false = omit from generated zimports blank imports + AutoInstall *bool `json:"auto_install"` // nil = false; true = install when all depends are installed + SwcEntry string `json:"swc_entry"` // optional SWC addon entry module URL + Assets []string `json:"assets"` // optional CSS/JS paths relative to addon root AssetBundles map[string][]string `json:"asset_bundles"` // named lazy bundles, e.g. swc.backend } diff --git a/core/module/xmlid_link.go b/core/module/xmlid_link.go index 16fa4eed..1b6bc06a 100644 --- a/core/module/xmlid_link.go +++ b/core/module/xmlid_link.go @@ -34,7 +34,7 @@ func resolveXMLIDInModule(ctx context.Context, moduleName, ref string) (int, err // linkXMLRecord upserts sys.model.data for a synced XML record and logs failures. func linkXMLRecord(ctx context.Context, moduleName, xmlID, model string, coreID int) error { - if coreID <= 0 || xmlID == "" { + if 0 >= coreID || xmlID == "" { return nil } _, err := orm.Upsert(ctx, orm.RegistryModel("sys.model.data"), map[string]interface{}{ diff --git a/core/orm/abac_context.go b/core/orm/abac_context.go index 0434358e..c6cc5519 100644 --- a/core/orm/abac_context.go +++ b/core/orm/abac_context.go @@ -16,13 +16,13 @@ func queryUserCompanyID(ctx context.Context, uid int) (sql.NullInt64, error) { // UserCompanyIDs returns allowed company ids for uid (M2M + current company_id). func UserCompanyIDs(ctx context.Context, uid int) ([]int64, error) { - if uid <= 0 || DB == nil { + if 0 >= uid || DB == nil { return nil, nil } seen := map[int64]struct{}{} var out []int64 add := func(id int64) { - if id <= 0 { + if 0 >= id { return } if _, ok := seen[id]; ok { @@ -57,14 +57,14 @@ func UserAllowedCompany(ctx context.Context, uid int, cid int64) bool { if uid == superuserUID { return true } - if cid <= 0 { + if 0 >= cid { return false } ids, err := UserCompanyIDs(ctx, uid) if err != nil { return false } - if len(ids) == 0 { + if 0 == len(ids) { active := ActiveCompanyIDForUser(ctx, uid) return active > 0 && cid == active } @@ -78,7 +78,7 @@ func UserAllowedCompany(ctx context.Context, uid int, cid int64) bool { // ActiveCompanyIDForUser reads core.user.company_id directly (no ORM Search/log path). func ActiveCompanyIDForUser(ctx context.Context, uid int) int64 { - if uid <= 0 || DB == nil { + if 0 >= uid || DB == nil { return 0 } companyID, _ := queryUserCompanyID(ctx, uid) @@ -92,7 +92,7 @@ func ActiveCompanyIDForUser(ctx context.Context, uid int) int64 { // UserCompanyIDsForUser returns allowed company ids from core.user.company.rel. func UserCompanyIDsForUser(ctx context.Context, userID int) ([]int, error) { - if userID <= 0 || DB == nil { + if 0 >= userID || DB == nil { return nil, nil } table := MustQuotedTableName("core.user.company.rel") @@ -114,12 +114,26 @@ func UserCompanyIDsForUser(ctx context.Context, userID int) ([]int, error) { return out, rows.Err() } +// addCompanyForCreator adds companyID to the signed-in user's allowed companies. +// No user on the context (install, setup) leaves membership unchanged. +func addCompanyForCreator(ctx context.Context, companyID int) error { + uid := SecurityUID(ctx) + if 0 >= uid || 0 >= companyID { + return nil + } + ids, err := UserCompanyIDsForUser(ctx, uid) + if err != nil { + return err + } + return SetUserCompanyLinks(ctx, uid, append(ids, companyID)) +} + // SetUserCompanyLinks replaces allowed-company membership for a user. func SetUserCompanyLinks(ctx context.Context, userID int, companyIDs []int) error { if DB == nil { return fmt.Errorf("no database") } - if userID <= 0 { + if 0 >= userID { return fmt.Errorf("invalid user id") } table := MustQuotedTableName("core.user.company.rel") @@ -128,7 +142,7 @@ func SetUserCompanyLinks(ctx context.Context, userID int, companyIDs []int) erro } seen := map[int]struct{}{} for _, companyID := range companyIDs { - if companyID <= 0 { + if 0 >= companyID { continue } if _, ok := seen[companyID]; ok { diff --git a/core/orm/action_lookup.go b/core/orm/action_lookup.go index 49eaae12..33ee4ccd 100644 --- a/core/orm/action_lookup.go +++ b/core/orm/action_lookup.go @@ -19,7 +19,7 @@ func ResolveActionRecord(ctx context.Context, actionID int, actionQuery string) if err != nil { return "", 0, err } - if id <= 0 { + if 0 >= id { return "", 0, fmt.Errorf("action %q not found", actionQuery) } return model, id, nil @@ -36,7 +36,7 @@ func ResolveActionRecord(ctx context.Context, actionID int, actionQuery string) } func actionModelForCoreID(ctx context.Context, coreID int) (string, int, error) { - if coreID <= 0 { + if 0 >= coreID { return "", 0, fmt.Errorf("action not found") } rows, err := Search(ctx, "sys.model.data", [][]interface{}{ @@ -46,7 +46,7 @@ func actionModelForCoreID(ctx context.Context, coreID int) (string, int, error) if err != nil { return "", 0, err } - if len(rows) == 0 { + if 0 == len(rows) { return "", 0, fmt.Errorf("action %d not found", coreID) } modelName := strings.TrimSpace(AsString(rows[0]["model"])) diff --git a/core/orm/action_result.go b/core/orm/action_result.go index 86869c84..cd8c32ab 100644 --- a/core/orm/action_result.go +++ b/core/orm/action_result.go @@ -29,7 +29,7 @@ type ActionResult struct { // ActionOpenURL builds a wizard workspace URL (model + id, dialog target). func ActionOpenURL(model string, id int) string { model = strings.TrimSpace(model) - if model == "" || id <= 0 { + if model == "" || 0 >= id { return "" } q := url.Values{} @@ -69,7 +69,7 @@ func parseWorkspaceOpen(redirect string) map[string]interface{} { } model := strings.TrimSpace(q.Get("model")) id, _ := strconv.Atoi(strings.TrimSpace(q.Get("id"))) - if model == "" || id <= 0 { + if model == "" || 0 >= id { return nil } viewType := strings.TrimSpace(q.Get("view_type")) diff --git a/core/orm/apikey.go b/core/orm/apikey.go index 6aa40b15..1fb4d2cd 100644 --- a/core/orm/apikey.go +++ b/core/orm/apikey.go @@ -45,7 +45,7 @@ func UIDFromAPIKey(ctx context.Context, raw string) int { err := DB.QueryRowContext(ctx, `SELECT user_id, active, expires_at FROM `+tbl+` WHERE key_hash = $1 LIMIT 1`, hash, ).Scan(&uid, &active, &expiresAt) - if err == sql.ErrNoRows || err != nil || !active || uid <= 0 { + if err == sql.ErrNoRows || err != nil || !active || 0 >= uid { return 0 } if expiresAt.Valid && strings.TrimSpace(expiresAt.String) != "" { @@ -67,7 +67,7 @@ func UIDFromAPIKey(ctx context.Context, raw string) int { // CreateAPIKeyForUser inserts a new key row. Returns the raw key (caller must show once). func CreateAPIKeyForUser(ctx context.Context, userID int, name string) (raw string, err error) { - if userID <= 0 { + if 0 >= userID { return "", fmt.Errorf("user id required") } inst, ok := Registry["core.user.apikey"] diff --git a/core/orm/attachment_access.go b/core/orm/attachment_access.go index fbd752d9..a59147c2 100644 --- a/core/orm/attachment_access.go +++ b/core/orm/attachment_access.go @@ -18,7 +18,7 @@ func CanReadAttachmentContent(ctx context.Context, uid int, att map[string]inter } resModel := strings.TrimSpace(AsString(att["model"])) resID, _ := CoerceInt64(att["res_id"]) - if resModel == "" || resID <= 0 { + if resModel == "" || 0 >= resID { return nil } if _, ok := Registry[resModel]; !ok { @@ -28,9 +28,13 @@ func CanReadAttachmentContent(ctx context.Context, uid int, att map[string]inter return err } row, err := SearchOne(ctx, resModel, map[string]interface{}{"id": resID}) - if err != nil || len(row) == 0 { + if err != nil { return fmt.Errorf("linked record not found") } + if 0 == len(row) { + return fmt.Errorf("linked record not found") + } + return nil } @@ -47,7 +51,7 @@ func ResolveContentAttachment(ctx context.Context, attachmentID int, resModel, r } resModel = strings.TrimSpace(resModel) resField = strings.TrimSpace(resField) - if resModel == "" || resField == "" || resID <= 0 { + if resModel == "" || resField == "" || 0 >= resID { return nil, fmt.Errorf("attachment not found") } domain := map[string]interface{}{ diff --git a/core/orm/attachment_binary.go b/core/orm/attachment_binary.go index 770b49ce..43b544c6 100644 --- a/core/orm/attachment_binary.go +++ b/core/orm/attachment_binary.go @@ -32,7 +32,7 @@ type CreateBinaryAttachmentInput struct { // CreateBinaryAttachment validates MIME, optional scan, filestore write, ORM create. func CreateBinaryAttachment(ctx context.Context, in CreateBinaryAttachmentInput) (int, error) { - if len(in.Data) == 0 { + if 0 == len(in.Data) { return 0, fmt.Errorf("attachment: empty data") } name := strings.TrimSpace(in.Name) diff --git a/core/orm/attachment_mime.go b/core/orm/attachment_mime.go index 33db3f34..8f4ade97 100644 --- a/core/orm/attachment_mime.go +++ b/core/orm/attachment_mime.go @@ -8,6 +8,7 @@ import ( var blockedAttachmentMIMEPrefixes = []string{ "text/html", + "image/svg+xml", "application/javascript", "application/x-javascript", "text/javascript", @@ -58,7 +59,7 @@ func normalizeAttachmentMIME(declaredMIME string, data []byte) string { if declaredMIME != "" { return declaredMIME } - if len(data) == 0 { + if 0 == len(data) { return "" } sniffLen := len(data) diff --git a/core/orm/audit.go b/core/orm/audit.go index a889dbea..5049f562 100644 --- a/core/orm/audit.go +++ b/core/orm/audit.go @@ -96,7 +96,7 @@ func RecordAuditLogAvailable(ctx context.Context) bool { if err != nil { return true } - if len(installed) == 0 { + if 0 == len(installed) { return true } _, ok := installed["audit"] @@ -106,13 +106,13 @@ func RecordAuditLogAvailable(ctx context.Context) bool { // SearchRecordAuditLog returns audit rows for one record. Caller must verify model read access. func SearchRecordAuditLog(ctx context.Context, model string, resID int, limit, offset int) ([]map[string]interface{}, error) { model = strings.TrimSpace(model) - if model == "" || resID <= 0 { + if model == "" || 0 >= resID { return nil, fmt.Errorf("invalid record") } if !RecordAuditLogAvailable(ctx) { return nil, nil } - if limit <= 0 || limit > recordAuditLogMax { + if 0 >= limit || limit > recordAuditLogMax { limit = recordAuditLogMax } if offset < 0 { diff --git a/core/orm/audit_format.go b/core/orm/audit_format.go index 92b3cc4d..f9e21537 100644 --- a/core/orm/audit_format.go +++ b/core/orm/audit_format.go @@ -85,7 +85,7 @@ func formatAuditDiff(ctx context.Context, model string, before, after map[string } } sort.Strings(keys) - if len(keys) == 0 { + if 0 == len(keys) { return "" } extra := 0 @@ -113,7 +113,7 @@ func formatAuditDiff(ctx context.Context, model string, before, after map[string } func formatAuditFieldLines(ctx context.Context, model string, _ map[string]interface{}, after map[string]interface{}, createMode bool) string { - if len(after) == 0 { + if 0 == len(after) { return "" } redact := sensitiveAuditFields(model) @@ -128,7 +128,7 @@ func formatAuditFieldLines(ctx context.Context, model string, _ map[string]inter keys = append(keys, k) } sort.Strings(keys) - if len(keys) == 0 { + if 0 == len(keys) { return "" } extra := 0 diff --git a/core/orm/bus_notify.go b/core/orm/bus_notify.go index 6543921d..04f7354a 100644 --- a/core/orm/bus_notify.go +++ b/core/orm/bus_notify.go @@ -7,7 +7,7 @@ import ( // NotifyBusEvent sends PostgreSQL NOTIFY for multi-process fan-out. func NotifyBusEvent(eventID int64) error { - if DB == nil || eventID <= 0 { + if DB == nil || 0 >= eventID { return nil } _, err := DB.Exec(`SELECT pg_notify($1, $2)`, BusNotifyChannel(), strconv.FormatInt(eventID, 10)) diff --git a/core/orm/bus_publish_hook.go b/core/orm/bus_publish_hook.go index f3501113..4cb14632 100644 --- a/core/orm/bus_publish_hook.go +++ b/core/orm/bus_publish_hook.go @@ -21,7 +21,7 @@ func PublishBusChannel(ctx context.Context, channel string, payload map[string]i // UserNotificationsBusChannel returns the inbox channel for uid. func UserNotificationsBusChannel(uid int) string { - if uid <= 0 { + if 0 >= uid { return "" } return "user/" + strconv.Itoa(uid) + "/notifications" diff --git a/core/orm/computed.go b/core/orm/computed.go index 5f999fe6..9491ea93 100644 --- a/core/orm/computed.go +++ b/core/orm/computed.go @@ -43,7 +43,7 @@ func ApplyComputes(ctx context.Context, model string, rec map[string]interface{} return nil } specs := snapshotComputeSpecs(model) - if len(specs) == 0 { + if 0 == len(specs) { return nil } for field, spec := range specs { diff --git a/core/orm/computed_store.go b/core/orm/computed_store.go index ac33479b..b604d243 100644 --- a/core/orm/computed_store.go +++ b/core/orm/computed_store.go @@ -15,7 +15,7 @@ func MergeStoredComputes(ctx context.Context, modelName string, rec map[string]i return nil } order := storedComputeOrder(modelName) - if len(order) == 0 { + if 0 == len(order) { return nil } specs := snapshotComputeSpecs(modelName) @@ -36,7 +36,7 @@ func MergeStoredComputes(ctx context.Context, modelName string, rec map[string]i func storedComputeOrder(modelName string) []string { computeMu.RLock() byField := computes[modelName] - if len(byField) == 0 { + if 0 == len(byField) { computeMu.RUnlock() return nil } @@ -97,7 +97,7 @@ func topoSort(fields []string, deps map[string][]string) []string { // RejectVirtualWrites returns an error if values touch virtual or readonly-compute fields. func RejectVirtualWrites(model Model, values map[string]interface{}) error { - if model == nil || len(values) == 0 { + if model == nil || 0 == len(values) { return nil } byName := fieldDefinitionsByName(model) diff --git a/core/orm/crud_domain.go b/core/orm/crud_domain.go index b07b60f4..bcf2bcdc 100644 --- a/core/orm/crud_domain.go +++ b/core/orm/crud_domain.go @@ -6,7 +6,7 @@ import ( ) func buildSearchWhereClause(modelName string, domain [][]interface{}) (string, []interface{}, error) { - if len(domain) == 0 { + if 0 == len(domain) { return "1=1", nil, nil } // Prefix Polish OR: ["|", ...] repeated (N-1) times then N leaf triples → OR of leaves. @@ -102,7 +102,7 @@ func buildSearchWhereClause(modelName string, domain [][]interface{}) (string, [ func appendDomainListClause(col, op string, list []interface{}, placeholderIndex int) (clause string, args []interface{}, nextIndex int) { if op == "in" { - if len(list) == 0 { + if 0 == len(list) { return "FALSE", nil, placeholderIndex } placeholders := make([]string, len(list)) @@ -113,7 +113,7 @@ func appendDomainListClause(col, op string, list []interface{}, placeholderIndex } return fmt.Sprintf("%s IN (%s)", col, strings.Join(placeholders, ",")), args, placeholderIndex } - if len(list) == 0 { + if 0 == len(list) { return "TRUE", nil, placeholderIndex } placeholders := make([]string, len(list)) @@ -126,7 +126,7 @@ func appendDomainListClause(col, op string, list []interface{}, placeholderIndex } func joinShiftedWhereFragments(separator, modelName string, domains [][][]interface{}, startPlaceholder int) (string, []interface{}, error) { - if len(domains) == 0 { + if 0 == len(domains) { return "1=1", nil, nil } var parts []string diff --git a/core/orm/crud_insert.go b/core/orm/crud_insert.go index 2ef2724b..00eaaf72 100644 --- a/core/orm/crud_insert.go +++ b/core/orm/crud_insert.go @@ -15,6 +15,12 @@ func Create(ctx context.Context, model Model, values map[string]interface{}) (id logORMOperation(ctx, start, "create", model.ModelName(), err, map[string]interface{}{"resource_id": id}) }() res, err := executeCreateMutation(ctx, model, values) + if err != nil { + return res.ID, err + } + if res.ID > 0 && model.ModelName() == "core.company" { + err = addCompanyForCreator(ctx, res.ID) + } return res.ID, err } @@ -57,7 +63,7 @@ func Upsert(ctx context.Context, model Model, values map[string]interface{}, con } } } - if len(prepared) == 0 { + if 0 == len(prepared) { return 0, fmt.Errorf("upsert requires at least one column") } @@ -85,7 +91,7 @@ func Upsert(ctx context.Context, model Model, values map[string]interface{}, con } updates = append(updates, fmt.Sprintf("%s = EXCLUDED.%s", qcol, qcol)) } - if len(updates) == 0 { + if 0 == len(updates) { updates = append(updates, fmt.Sprintf("%s = EXCLUDED.%s", conflictQuoted, conflictQuoted)) } diff --git a/core/orm/crud_mutate.go b/core/orm/crud_mutate.go index aefb438f..744820be 100644 --- a/core/orm/crud_mutate.go +++ b/core/orm/crud_mutate.go @@ -97,7 +97,7 @@ func executeUpdateMutation(ctx context.Context, modelName string, domain [][]int if err := CheckFieldWriteAccess(ctx, uid, modelName, prepared); err != nil { return result, err } - if len(prepared) == 0 { + if 0 == len(prepared) { return result, nil } table, err := QuotedTableForModel(modelName) @@ -115,7 +115,7 @@ func executeUpdateMutation(ctx context.Context, modelName string, domain [][]int if err != nil { return err } - if len(locked) == 0 { + if 0 == len(locked) { return fmt.Errorf("access denied or record not found") } var previous map[string]interface{} @@ -176,7 +176,7 @@ func executeUpdateMutation(ctx context.Context, modelName string, domain [][]int return err } n, _ := res.RowsAffected() - if n == 0 { + if 0 == n { return fmt.Errorf("access denied or record not found") } result.RowsAffected = n @@ -238,7 +238,7 @@ func executeDeleteMutation(ctx context.Context, modelName string, domain [][]int return err } n, _ := res.RowsAffected() - if n == 0 { + if 0 == n { return fmt.Errorf("access denied or record not found") } result.RowsAffected = n diff --git a/core/orm/crud_prepare.go b/core/orm/crud_prepare.go index a8f4ddd5..82b01cdf 100644 --- a/core/orm/crud_prepare.go +++ b/core/orm/crud_prepare.go @@ -30,6 +30,7 @@ func prepareCreateWrite(ctx context.Context, model Model, values map[string]inte if err := applySpecialDefaults(ctx, fieldDefs, prepared); err != nil { return nil, 0, err } + stampActiveCompanyOnCreate(ctx, model.ModelName(), prepared) if err := MergePropertiesWrite(ctx, model.ModelName(), nil, prepared); err != nil { return nil, 0, err } @@ -51,7 +52,7 @@ func prepareCreateWrite(ctx context.Context, model Model, values map[string]inte if err := runConstraints(ctx, model.ModelName(), mergeRecordMap(map[string]interface{}{}, prepared)); err != nil { return nil, 0, err } - if len(prepared) == 0 { + if 0 == len(prepared) { return nil, 0, fmt.Errorf("create requires at least one column") } return prepared, uid, nil diff --git a/core/orm/crud_search.go b/core/orm/crud_search.go index 7578474f..b2224339 100644 --- a/core/orm/crud_search.go +++ b/core/orm/crud_search.go @@ -163,7 +163,7 @@ func SearchPage(ctx context.Context, modelName string, domain [][]interface{}, l } logORMOperation(ctx, start, "search_page", modelName, err, map[string]interface{}{"rows": n, "limit": limit, "offset": offset}) }() - if limit <= 0 || limit > maxSearchLimit { + if 0 >= limit || limit > maxSearchLimit { limit = maxSearchLimit } if offset < 0 { diff --git a/core/orm/crud_search_one.go b/core/orm/crud_search_one.go index 2d5d5262..d40cf04f 100644 --- a/core/orm/crud_search_one.go +++ b/core/orm/crud_search_one.go @@ -11,7 +11,7 @@ import ( // CriteriaToDomain converts a SearchOne equality map into a conjunctive domain // (sorted keys so SQL placeholder order is stable). func CriteriaToDomain(criteria map[string]interface{}) [][]interface{} { - if len(criteria) == 0 { + if 0 == len(criteria) { return nil } keys := make([]string, 0, len(criteria)) @@ -47,7 +47,7 @@ func SearchOne(ctx context.Context, modelName string, criteria map[string]interf } domain := CriteriaToDomain(criteria) - if len(domain) == 0 { + if 0 == len(domain) { return nil, fmt.Errorf("search criteria required") } diff --git a/core/orm/crud_tx.go b/core/orm/crud_tx.go index fb26187a..1b700a3c 100644 --- a/core/orm/crud_tx.go +++ b/core/orm/crud_tx.go @@ -31,7 +31,7 @@ func insertRawOnTx(ctx context.Context, tx TxWrapper, model Model, values map[st if err != nil { return 0, err } - if len(prepared) == 0 { + if 0 == len(prepared) { return 0, fmt.Errorf("insert requires at least one column") } return insertPreparedOnTx(ctx, tx, model, prepared) @@ -78,7 +78,7 @@ func lockRowsForDomain(ctx context.Context, tx TxWrapper, table, whereSQL string if err := rows.Err(); err != nil { return nil, err } - if len(locked) == 0 { + if 0 == len(locked) { return nil, fmt.Errorf("access denied or record not found") } return locked, nil diff --git a/core/orm/crud_unlink.go b/core/orm/crud_unlink.go index dc254404..c7510ce8 100644 --- a/core/orm/crud_unlink.go +++ b/core/orm/crud_unlink.go @@ -12,7 +12,7 @@ func Unlink(ctx context.Context, modelName string, id int) (err error) { defer func() { logORMOperation(ctx, start, "delete", modelName, err, map[string]interface{}{"resource_id": id}) }() - if id <= 0 { + if 0 >= id { return fmt.Errorf("invalid id") } _, err = UnlinkWhere(ctx, modelName, [][]interface{}{{"id", "=", id}}) diff --git a/core/orm/crud_update.go b/core/orm/crud_update.go index 7018a027..b9c6ec5e 100644 --- a/core/orm/crud_update.go +++ b/core/orm/crud_update.go @@ -12,7 +12,7 @@ func UpdateRecordByID(ctx context.Context, modelName string, id int, values map[ defer func() { logORMOperation(ctx, start, "write", modelName, err, map[string]interface{}{"resource_id": id}) }() - if id <= 0 { + if 0 >= id { return fmt.Errorf("invalid id") } _, err = Update(ctx, modelName, [][]interface{}{{"id", "=", id}}, values) diff --git a/core/orm/db_dev_log.go b/core/orm/db_dev_log.go index 12efc274..f4881298 100644 --- a/core/orm/db_dev_log.go +++ b/core/orm/db_dev_log.go @@ -39,7 +39,7 @@ func logSQL(ctx context.Context, op, query string, args []interface{}, err error } func scrubSQLArgs(query string, args []interface{}) interface{} { - if len(args) == 0 { + if 0 == len(args) { return nil } if applog.TextContainsSecretKeyword(query) { diff --git a/core/orm/debug_access_trace.go b/core/orm/debug_access_trace.go index fcb64147..1af592ec 100644 --- a/core/orm/debug_access_trace.go +++ b/core/orm/debug_access_trace.go @@ -12,7 +12,7 @@ const debugRuleDomainMax = 512 // BuildDebugAccessTrace returns ACL/rule summary for the current user (support UI only). func BuildDebugAccessTrace(ctx context.Context, uid int, model string) (map[string]interface{}, error) { model = strings.TrimSpace(model) - if uid <= 0 { + if 0 >= uid { return nil, fmt.Errorf("login required") } if UserIsPortalOnly(ctx, uid) { @@ -67,14 +67,14 @@ func BuildDebugAccessTrace(ctx context.Context, uid int, model string) (map[stri ruleSummary, truncated := summarizeRuleParts(parts) out := map[string]interface{}{ - "uid": uid, - "model": model, - "company_id": dc.CompanyID, + "uid": uid, + "model": model, + "company_id": dc.CompanyID, "allowed_companies": dc.CompanyIDs, - "group_ids": groups, - "model_read_ok": true, - "fields": fields, - "rule_domain": ruleSummary, + "group_ids": groups, + "model_read_ok": true, + "fields": fields, + "rule_domain": ruleSummary, } if truncated { out["rule_domain_truncated"] = true diff --git a/core/orm/debug_record_metadata.go b/core/orm/debug_record_metadata.go index 4711a01e..50812a81 100644 --- a/core/orm/debug_record_metadata.go +++ b/core/orm/debug_record_metadata.go @@ -11,7 +11,7 @@ const debugMetadataXmlIDCap = 50 // BuildDebugRecordMetadata returns record audit + XML id rows for developer UI (admin support only). func BuildDebugRecordMetadata(ctx context.Context, uid int, model string, recordID int) (map[string]interface{}, error) { model = strings.TrimSpace(model) - if uid <= 0 { + if 0 >= uid { return nil, fmt.Errorf("login required") } if UserIsPortalOnly(ctx, uid) { @@ -20,7 +20,7 @@ func BuildDebugRecordMetadata(ctx context.Context, uid int, model string, record if model == "" { return nil, fmt.Errorf("model required") } - if recordID <= 0 { + if 0 >= recordID { return nil, fmt.Errorf("record_id required") } if RegistryModel(model) == nil { @@ -34,7 +34,7 @@ func BuildDebugRecordMetadata(ctx context.Context, uid int, model string, record if err != nil { return nil, err } - if len(rows) == 0 { + if 0 == len(rows) { return nil, fmt.Errorf("record not found") } rec := rows[0] diff --git a/core/orm/domain_json.go b/core/orm/domain_json.go index 88811792..be656d3c 100644 --- a/core/orm/domain_json.go +++ b/core/orm/domain_json.go @@ -39,7 +39,7 @@ func ParseDomainJSON(raw string) ([][]interface{}, error) { // ResolveDomainXMLRefs replaces many2one-style string values that look like XML ids // (module.name) with their resolved integer ids. func ResolveDomainXMLRefs(ctx context.Context, domain [][]interface{}) [][]interface{} { - if len(domain) == 0 { + if 0 == len(domain) { return domain } out := make([][]interface{}, len(domain)) @@ -59,7 +59,7 @@ func ResolveDomainXMLRefs(ctx context.Context, domain [][]interface{}) [][]inter continue } id, _, err := ResolveXmlId(ctx, s) - if err != nil || id <= 0 { + if err != nil || 0 >= id { out[i] = d continue } @@ -83,7 +83,7 @@ type DomainContext struct { // SubstituteDomainContext replaces $uid, $company_id, and $company_ids in domain values. func SubstituteDomainContext(domain [][]interface{}, dc DomainContext) [][]interface{} { - if len(domain) == 0 { + if 0 == len(domain) { return domain } out := make([][]interface{}, len(domain)) diff --git a/core/orm/domain_or.go b/core/orm/domain_or.go index bb14da2a..5203a3af 100644 --- a/core/orm/domain_or.go +++ b/core/orm/domain_or.go @@ -12,7 +12,7 @@ func splitDomainORPrefix(domain [][]interface{}) (orCount int, leaves [][]interf } break } - if orCount == 0 { + if 0 == orCount { return 0, nil, true } leaves = domain[orCount:] diff --git a/core/orm/elevated_context.go b/core/orm/elevated_context.go index 04d4fa61..83a5e968 100644 --- a/core/orm/elevated_context.go +++ b/core/orm/elevated_context.go @@ -36,7 +36,7 @@ func RejectSmuggledUserBypass(ctx context.Context) error { return nil } uid := UIDFromContext(ctx) - if uid <= 0 || uid == superuserUID { + if 0 >= uid || uid == superuserUID { return nil } return &SmuggledBypassError{UID: uid} diff --git a/core/orm/falsy_domain.go b/core/orm/falsy_domain.go index 39c7602b..8c50f530 100644 --- a/core/orm/falsy_domain.go +++ b/core/orm/falsy_domain.go @@ -179,7 +179,7 @@ func cellIsFalsyEmpty(modelName, fieldName string, cellValue interface{}) bool { return true } if id, ok := CoerceInt64(cellValue); ok { - return id == 0 + return 0 == id } return false case falsyEmpty: @@ -189,10 +189,10 @@ func cellIsFalsyEmpty(modelName, fieldName string, cellValue interface{}) bool { return true } if n, ok := CoerceInt64(cellValue); ok { - return n == 0 + return 0 == n } f, ok := toFloat64(cellValue) - return ok && f == 0 + return ok && 0 == f case falsyRelOne2Many, falsyRelMany2Many: return relationalCellEmpty(cellValue) case falsyNone: @@ -210,16 +210,16 @@ func relationalCellEmpty(cellValue interface{}) bool { case nil: return true case []interface{}: - return len(v) == 0 + return 0 == len(v) case []int: - return len(v) == 0 + return 0 == len(v) case []int64: - return len(v) == 0 + return 0 == len(v) case []map[string]interface{}: - return len(v) == 0 + return 0 == len(v) default: if id, ok := CoerceInt64(cellValue); ok { - return id == 0 + return 0 == id } return strings.TrimSpace(AsString(cellValue)) == "" } diff --git a/core/orm/field_read.go b/core/orm/field_read.go index c17f7678..fa1ac226 100644 --- a/core/orm/field_read.go +++ b/core/orm/field_read.go @@ -13,7 +13,7 @@ func sensitiveReadFields(model string) map[string]bool { // CheckFieldReadAccess errors if any requested field is read-denied by sys.field.access. func CheckFieldReadAccess(ctx context.Context, uid int, model string, fields []string) error { - if SecurityBypass(ctx) || uid == superuserUID || len(fields) == 0 { + if SecurityBypass(ctx) || uid == superuserUID || 0 == len(fields) { return nil } denied, err := fieldAccessDenied(ctx, uid, model, "read") diff --git a/core/orm/filestore.go b/core/orm/filestore.go index f6b454f9..8a01d623 100644 --- a/core/orm/filestore.go +++ b/core/orm/filestore.go @@ -54,7 +54,7 @@ func filestorePath(key string) (string, error) { // StoreAttachment writes blob bytes and returns the stored filename key. func StoreAttachment(_ context.Context, name string, data []byte) (storeFname string, size int64, err error) { - if len(data) == 0 { + if 0 == len(data) { return "", 0, fmt.Errorf("filestore: empty data") } if err := ValidateAttachmentMIME(normalizeAttachmentMIME("", data), data); err != nil { diff --git a/core/orm/group_effective.go b/core/orm/group_effective.go index ac4bedcd..2b7fbd36 100644 --- a/core/orm/group_effective.go +++ b/core/orm/group_effective.go @@ -10,7 +10,7 @@ import ( // EffectiveGroupIDs returns all group ids for uid (direct + implied). Superuser gets all group ids. func EffectiveGroupIDs(ctx context.Context, uid int) ([]int, error) { - if uid <= 0 || DB == nil { + if 0 >= uid || DB == nil { return nil, nil } cacheKey := fmt.Sprintf("eff_groups:%d", uid) @@ -112,7 +112,7 @@ func intSliceContains(haystack []int, needle int) bool { // InvalidateEffectiveGroups drops the cached effective group set for uid. func InvalidateEffectiveGroups(_ context.Context, uid int) { - if uid <= 0 { + if 0 >= uid { return } cache.Delete(fmt.Sprintf("eff_groups:%d", uid)) diff --git a/core/orm/i18n_translate.go b/core/orm/i18n_translate.go index c94dcb73..13e2fe0f 100644 --- a/core/orm/i18n_translate.go +++ b/core/orm/i18n_translate.go @@ -40,8 +40,21 @@ func TranslateFieldLabel(ctx context.Context, modelName, fieldName, defaultLabel return defaultLabel } -// UserLang returns the active language code for ctx, defaulting to en_US. +// UserLang returns core.user.lang for the user on ctx, or en_US when unset. func UserLang(ctx context.Context) string { - _ = ctx + if DB == nil { + return "en_US" + } + uid := UIDFromContext(ctx) + if 0 >= uid { + return "en_US" + } + user, err := SearchOne(ctx, "core.user", map[string]interface{}{"id": uid}) + if err != nil { + return "en_US" + } + if lang := strings.TrimSpace(AsString(user["lang"])); lang != "" { + return lang + } return "en_US" } diff --git a/core/orm/list_search_domain.go b/core/orm/list_search_domain.go index c9f33ce8..a7213205 100644 --- a/core/orm/list_search_domain.go +++ b/core/orm/list_search_domain.go @@ -5,7 +5,7 @@ import "strings" // BuildListSearchDomain OR-combines ilike clauses for searchable string fields. func BuildListSearchDomain(modelName string, fieldNames []string, query string) [][]interface{} { query = strings.TrimSpace(query) - if query == "" || len(fieldNames) == 0 { + if query == "" || 0 == len(fieldNames) { return nil } pattern := "%" + query + "%" @@ -20,7 +20,7 @@ func BuildListSearchDomain(modelName string, fieldNames []string, query string) } leaves = append(leaves, []interface{}{f, "ilike", pattern}) } - if len(leaves) == 0 { + if 0 == len(leaves) { return nil } if len(leaves) == 1 { @@ -55,10 +55,10 @@ func fieldSearchable(modelName, field string) bool { // MergeDomains AND-combines two domains (base action domain + optional search overlay). func MergeDomains(base, extra [][]interface{}) [][]interface{} { - if len(extra) == 0 { + if 0 == len(extra) { return base } - if len(base) == 0 { + if 0 == len(base) { return extra } out := make([][]interface{}, 0, len(base)+len(extra)) diff --git a/core/orm/menu_access.go b/core/orm/menu_access.go index c8307ab3..1079675d 100644 --- a/core/orm/menu_access.go +++ b/core/orm/menu_access.go @@ -13,7 +13,7 @@ func UserHasAnyAccessGroup(ctx context.Context, uid int, accessGroupsCSV string) return true } ids := NormalizeAccessGroupList(accessGroupsCSV) - if len(ids) == 0 { + if 0 == len(ids) { return true } return userMatchesAnyGroupXML(ctx, uid, ids) @@ -26,7 +26,7 @@ func UserMayAccessMenu(ctx context.Context, uid int, accessGroupsCSV string) boo return true } ids := NormalizeAccessGroupList(accessGroupsCSV) - if len(ids) == 0 { + if 0 == len(ids) { return UserHasGroupXML(ctx, uid, "base.group_system") } return userMatchesAnyGroupXML(ctx, uid, ids) @@ -38,23 +38,27 @@ func UserHasGroupXML(ctx context.Context, uid int, xmlID string) bool { return true } xmlID = strings.TrimSpace(xmlID) - if xmlID == "" || uid <= 0 { + if xmlID == "" || 0 >= uid { return false } return userMatchesAnyGroupXML(ctx, uid, []string{xmlID}) } func userMatchesAnyGroupXML(ctx context.Context, uid int, xmlIDs []string) bool { - if uid <= 0 { + if 0 >= uid { return false } eff, err := EffectiveGroupIDs(ctx, uid) - if err != nil || len(eff) == 0 { + if err != nil { return false } + if 0 == len(eff) { + return false + } + for _, xmlID := range xmlIDs { gid, _, err := ResolveXmlId(ctx, xmlID) - if err != nil || gid == 0 { + if err != nil || 0 == gid { continue } if intSliceContains(eff, gid) { @@ -76,7 +80,7 @@ func SetUserGroupLinks(ctx context.Context, userID int, groupIDs []int) error { return err } for _, gid := range groupIDs { - if gid <= 0 { + if 0 >= gid { continue } if _, err := DB.ExecContext(ctx, `INSERT INTO `+tbl+` (user_id, group_id) VALUES ($1, $2) ON CONFLICT (user_id, group_id) DO NOTHING`, userID, gid); err != nil { @@ -100,7 +104,7 @@ func normalizeUserTypeGroupIDs(ctx context.Context, groupIDs []int) []int { var out []int seen := map[int]struct{}{} for _, gid := range groupIDs { - if gid <= 0 { + if 0 >= gid { continue } if x, ok := typeIDs[gid]; ok { diff --git a/core/orm/menu_walk.go b/core/orm/menu_walk.go index 79b0bc54..a991feeb 100644 --- a/core/orm/menu_walk.go +++ b/core/orm/menu_walk.go @@ -6,7 +6,7 @@ import ( // MenuHasAncestor reports whether startID is rootID or a descendant (walk parent_id, max 64). func MenuHasAncestor(ctx context.Context, startID, rootID int) bool { - if startID <= 0 || rootID <= 0 || DB == nil { + if 0 >= startID || 0 >= rootID || DB == nil { return false } cur := startID @@ -19,7 +19,7 @@ func MenuHasAncestor(ctx context.Context, startID, rootID int) bool { return false } pid, ok := CoerceInt64(row["parent_id"]) - if !ok || pid == 0 { + if !ok || 0 == pid { return false } cur = int(pid) diff --git a/core/orm/model.go b/core/orm/model.go index 88b9ab4d..b94cd041 100644 --- a/core/orm/model.go +++ b/core/orm/model.go @@ -3,19 +3,19 @@ package orm type FieldType string const ( - Char FieldType = "char" - Text FieldType = "text" - Integer FieldType = "integer" - Float FieldType = "float" - Float64 FieldType = "float64" - Numeric FieldType = "numeric" // For exact decimal precision (money) - Boolean FieldType = "boolean" - Date FieldType = "date" - DateTime FieldType = "datetime" - Selection FieldType = "selection" - Many2One FieldType = "many2one" - Many2Many FieldType = "many2many" - One2Many FieldType = "one2many" + Char FieldType = "char" + Text FieldType = "text" + Integer FieldType = "integer" + Float FieldType = "float" + Float64 FieldType = "float64" + Numeric FieldType = "numeric" // For exact decimal precision (money) + Boolean FieldType = "boolean" + Date FieldType = "date" + DateTime FieldType = "datetime" + Selection FieldType = "selection" + Many2One FieldType = "many2one" + Many2Many FieldType = "many2many" + One2Many FieldType = "one2many" Json FieldType = "json" Properties FieldType = "properties" ) diff --git a/core/orm/model_acl.go b/core/orm/model_acl.go index bacd1056..c80eeddc 100644 --- a/core/orm/model_acl.go +++ b/core/orm/model_acl.go @@ -18,7 +18,7 @@ func CheckModelAccess(ctx context.Context, uid int, model string, op string) err if _, ok := Registry[model]; !ok { return fmt.Errorf("unknown model %q", model) } - if uid <= 0 { + if 0 >= uid { LogAccessDeny(ctx, model, op, "not authenticated") return &AccessDeniedError{Model: model, Operation: op} } diff --git a/core/orm/model_module.go b/core/orm/model_module.go index ecd7a181..df69c0dc 100644 --- a/core/orm/model_module.go +++ b/core/orm/model_module.go @@ -9,7 +9,7 @@ import ( // PlatformModules materialize on a fresh DB and cannot be uninstalled. var PlatformModules = map[string]bool{ "base": true, - "im": true, + "im": true, } // IsPlatformModule reports whether name is part of the always-on platform spine. diff --git a/core/orm/object_action.go b/core/orm/object_action.go index 02df7b6d..45219c89 100644 --- a/core/orm/object_action.go +++ b/core/orm/object_action.go @@ -39,7 +39,7 @@ func RunObjectAction(ctx context.Context, model string, id int, method string, v if model == "" || method == "" { return "", fmt.Errorf("model and method are required") } - if id <= 0 { + if 0 >= id { return "", fmt.Errorf("invalid record id") } if err := RejectSmuggledUserBypass(ctx); err != nil { diff --git a/core/orm/outbox_drain.go b/core/orm/outbox_drain.go index a26c810f..15848616 100644 --- a/core/orm/outbox_drain.go +++ b/core/orm/outbox_drain.go @@ -134,7 +134,7 @@ func StartOutboxDrain(parent context.Context, every time.Duration) { if outboxCancel != nil { return } - if every <= 0 { + if 0 >= every { every = 5 * time.Second } ctx, cancel := context.WithCancel(parent) diff --git a/core/orm/portal_policy.go b/core/orm/portal_policy.go index 41f9896a..b7d8764a 100644 --- a/core/orm/portal_policy.go +++ b/core/orm/portal_policy.go @@ -4,7 +4,7 @@ import "context" // UserIsPortalOnly reports portal group without internal user group. func UserIsPortalOnly(ctx context.Context, uid int) bool { - if uid <= 0 || uid == superuserUID { + if 0 >= uid || uid == superuserUID { return false } if !UserHasGroupXML(ctx, uid, "base.group_portal") { @@ -20,7 +20,7 @@ var portalRPCAllowlist = map[string]bool{ // EnforcePortalRPCModel restricts portal-only users to allowlisted RPC models. func EnforcePortalRPCModel(ctx context.Context, model string) error { uid := UIDFromContext(ctx) - if uid <= 0 || !UserIsPortalOnly(ctx, uid) { + if 0 >= uid || !UserIsPortalOnly(ctx, uid) { return nil } if portalRPCAllowlist[model] { diff --git a/core/orm/properties.go b/core/orm/properties.go index 56e433f6..80648ada 100644 --- a/core/orm/properties.go +++ b/core/orm/properties.go @@ -226,7 +226,7 @@ func checkPropertyValue(def PropertyDef, value interface{}) (interface{}, error) if !ok { return nil, fmt.Errorf("property %q must be an integer", def.Key) } - if def.ValueType == "many2one" && n <= 0 { + if def.ValueType == "many2one" && 0 >= n { return nil, fmt.Errorf("property %q must be a positive integer", def.Key) } return n, nil diff --git a/core/orm/read_group.go b/core/orm/read_group.go index b87a2d36..7bc0ec23 100644 --- a/core/orm/read_group.go +++ b/core/orm/read_group.go @@ -78,7 +78,10 @@ func ReadGroupDirect(ctx context.Context, modelName string, domain [][]interface if err := CheckModelAccess(ctx, uid, modelName, "read"); err != nil { return nil, err } - if len(spec.GroupBy) == 0 { + if err := CheckFieldReadAccess(ctx, uid, modelName, readGroupFields(spec)); err != nil { + return nil, err + } + if 0 == len(spec.GroupBy) { return nil, fmt.Errorf("groupby required") } @@ -155,3 +158,20 @@ func ReadGroupDirect(ctx context.Context, modelName string, domain [][]interface } return out, rows.Err() } + +func readGroupFields(spec ReadGroupSpec) []string { + fields := make([]string, 0, len(spec.GroupBy)+len(spec.Fields)) + for _, name := range spec.GroupBy { + name = strings.TrimSpace(name) + if name != "" { + fields = append(fields, name) + } + } + for _, f := range spec.Fields { + name := strings.TrimSpace(f.Field) + if name != "" { + fields = append(fields, name) + } + } + return fields +} diff --git a/core/orm/record_rules.go b/core/orm/record_rules.go index 891bba58..b6eed66d 100644 --- a/core/orm/record_rules.go +++ b/core/orm/record_rules.go @@ -50,7 +50,7 @@ func loadRuleDomainParts(ctx context.Context, uid int, model string, op string) if SecurityBypass(ctx) || uid == superuserUID { return empty, nil } - if uid <= 0 { + if 0 >= uid { return empty, nil } groups, err := EffectiveGroupIDs(ctx, uid) @@ -144,7 +144,7 @@ func loadRuleDomainsFromDB(ctx context.Context, model, op string, groups []int, } dom = SubstituteDomainContext(dom, dc) gids := groupsByRule[r.id] - if len(gids) == 0 { + if 0 == len(gids) { if len(dom) > 0 { out.globals = append(out.globals, dom) } @@ -160,7 +160,7 @@ func loadRuleDomainsFromDB(ctx context.Context, model, op string, groups []int, if !match { continue } - if len(dom) == 0 { + if 0 == len(dom) { out.allowAllGroups = true continue } @@ -182,11 +182,11 @@ func BuildWhereWithRecordRules(ctx context.Context, uid int, model, op string, b Operation: "dev_access_rules", Status: "success", Context: map[string]interface{}{ - "resource": model, - "op": op, - "uid": uid, - "global_rules": len(parts.globals), - "group_rules": len(parts.groups), + "resource": model, + "op": op, + "uid": uid, + "global_rules": len(parts.globals), + "group_rules": len(parts.groups), "allow_all_groups": parts.allowAllGroups, }, }) @@ -210,10 +210,10 @@ func BuildWhereWithRecordRules(ctx context.Context, uid int, model, op string, b if err != nil { return "", nil, err } - if andSQL == "1=1" && len(clauses) == 0 { + if andSQL == "1=1" && 0 == len(clauses) { return orSQL, orArgs, nil } - if len(clauses) == 0 { + if 0 == len(clauses) { return orSQL, orArgs, nil } shifted := shiftPlaceholders(orSQL, len(andArgs)+1) @@ -232,7 +232,7 @@ func CheckRecordRules(ctx context.Context, uid int, model string, op string, rec if SecurityBypass(ctx) || uid == superuserUID { return nil } - if uid <= 0 { + if 0 >= uid { return &AccessDeniedError{Model: model, Operation: op} } parts, err := loadRuleDomainParts(ctx, uid, model, op) @@ -247,7 +247,7 @@ func CheckRecordRules(ctx context.Context, uid int, model string, op string, rec if parts.allowAllGroups { return nil } - if len(parts.groups) == 0 { + if 0 == len(parts.groups) { return nil } for _, dom := range parts.groups { diff --git a/core/orm/registry.go b/core/orm/registry.go index 1f84d31d..7e2efa36 100644 --- a/core/orm/registry.go +++ b/core/orm/registry.go @@ -49,7 +49,7 @@ func SyncModels() error { } for _, model := range Registry { name := model.ModelName() - if len(installed) == 0 { + if 0 == len(installed) { owner := DeclaringModule(name) if owner != "" && !IsPlatformModule(owner) { continue @@ -70,7 +70,7 @@ func ColumnTypeSQL(f FieldDefinition) (string, bool) { switch f.Type { case Char: size := f.Size - if size <= 0 { + if 0 >= size { size = 255 } return fmt.Sprintf("VARCHAR(%d)", size), true @@ -84,7 +84,7 @@ func ColumnTypeSQL(f FieldDefinition) (string, bool) { return "DOUBLE PRECISION", true case Numeric: prec, scale := f.Precision, f.Scale - if prec <= 0 { + if 0 >= prec { prec = 16 } if scale < 0 { diff --git a/core/orm/rel_search.go b/core/orm/rel_search.go index f68c0842..6fdece67 100644 --- a/core/orm/rel_search.go +++ b/core/orm/rel_search.go @@ -29,7 +29,7 @@ func ResolveInverseOne2ManyField(parentModel, comodel string) string { // DisplayNameForID returns a short label for a related record id. func DisplayNameForID(ctx context.Context, modelName string, id int) string { - if id <= 0 || strings.TrimSpace(modelName) == "" { + if 0 >= id || strings.TrimSpace(modelName) == "" { return "" } rec, err := SearchOne(ctx, modelName, map[string]interface{}{"id": id}) diff --git a/core/orm/related.go b/core/orm/related.go index 5c292549..655acf00 100644 --- a/core/orm/related.go +++ b/core/orm/related.go @@ -28,7 +28,7 @@ func ApplyRelatedFields(ctx context.Context, model string, rec map[string]interf // ApplyRelatedFieldsBatch resolves related fields for many rows with one Search per relation hop. func ApplyRelatedFieldsBatch(ctx context.Context, model string, records []map[string]interface{}) error { - if len(records) == 0 { + if 0 == len(records) { return nil } inst, ok := Registry[model] @@ -63,7 +63,7 @@ func ApplyRelatedFieldsBatch(ctx context.Context, model string, records []map[st relation: relationFieldDef.Relation, }) } - if len(specs) == 0 { + if 0 == len(specs) { return nil } @@ -85,7 +85,7 @@ func ApplyRelatedFieldsBatch(ctx context.Context, model string, records []map[st ctx = ContextSkipRelatedEnrichment(ctx) cacheByRelation := map[string]map[int64]map[string]interface{}{} for relation, idSet := range idsByRelation { - if len(idSet) == 0 { + if 0 == len(idSet) { continue } values := make([]interface{}, 0, len(idSet)) @@ -110,7 +110,7 @@ func ApplyRelatedFieldsBatch(ctx context.Context, model string, records []map[st for _, rec := range records { for _, spec := range specs { relID, ok := CoerceInt64(rec[spec.relField]) - if !ok || relID <= 0 { + if !ok || 0 >= relID { rec[spec.fieldName] = nil continue } diff --git a/core/orm/schema_sync.go b/core/orm/schema_sync.go index 2055f169..cdf34bd3 100644 --- a/core/orm/schema_sync.go +++ b/core/orm/schema_sync.go @@ -31,7 +31,7 @@ func SyncRegistrySchemaContext(ctx context.Context) error { } names := make([]string, 0, len(Registry)) for name := range Registry { - if len(installed) == 0 { + if 0 == len(installed) { if owner := DeclaringModule(name); owner != "" && !IsPlatformModule(owner) { continue } @@ -262,7 +262,6 @@ func ensureModelIndexes(ctx context.Context, tbl schemaTable) error { return nil } - func pgIdentOK(name string) bool { if name == "" { return false diff --git a/core/orm/security_context.go b/core/orm/security_context.go index 799f50e5..7af0e060 100644 --- a/core/orm/security_context.go +++ b/core/orm/security_context.go @@ -30,7 +30,7 @@ func UIDFromContext(ctx context.Context) int { // ContextWithCompanyID returns a context with the user's active company id. func ContextWithCompanyID(ctx context.Context, companyID int64) context.Context { - if companyID <= 0 { + if 0 >= companyID { return ctx } return context.WithValue(ctx, companyIDKey, companyID) diff --git a/core/orm/security_schema.go b/core/orm/security_schema.go index 3e976bff..b7d85dff 100644 --- a/core/orm/security_schema.go +++ b/core/orm/security_schema.go @@ -42,7 +42,7 @@ func EnsureSecurityJoinIndexes() error { // NullableGroupIDForAccess returns SQL argument for sys.access.group_id (NULL if 0). func NullableGroupIDForAccess(groupID int) interface{} { - if groupID <= 0 { + if 0 >= groupID { return nil } return int64(groupID) diff --git a/core/orm/setup_seed.go b/core/orm/setup_seed.go index 658d9b30..31cf5032 100644 --- a/core/orm/setup_seed.go +++ b/core/orm/setup_seed.go @@ -114,9 +114,47 @@ func ensureDefaultKernelGroups(ctx context.Context) (adminGID int, userGID int, if _, err := DB.ExecContext(ctx, `INSERT INTO `+MustQuotedTableName(tableGroupImplied)+` (group_id, implied_group_id) VALUES ($1, $2) ON CONFLICT (group_id, implied_group_id) DO NOTHING`, adminGID, userGID); err != nil { return 0, 0, fmt.Errorf("bootstrap group imply: %w", err) } + + moduleManagerGID, err := Upsert(ctx, groupModel, map[string]interface{}{ + "name": "Administration / Apps", + "category_id": catAdminID, + "sequence": 5, + }, "name") + if err != nil { + return 0, 0, fmt.Errorf("bootstrap core.group module manager: %w", err) + } + if err := seedXmlID(ctx, "base", "group_module_manager", "core.group", moduleManagerGID); err != nil { + return 0, 0, err + } + if _, err := DB.ExecContext(ctx, `INSERT INTO `+MustQuotedTableName(tableGroupImplied)+` (group_id, implied_group_id) VALUES ($1, $2) ON CONFLICT (group_id, implied_group_id) DO NOTHING`, moduleManagerGID, userGID); err != nil { + return 0, 0, fmt.Errorf("bootstrap module manager imply user: %w", err) + } + if err := ensureModuleManagerACL(ctx, moduleManagerGID); err != nil { + return 0, 0, err + } + return adminGID, userGID, nil } +func ensureModuleManagerACL(ctx context.Context, moduleManagerGID int) error { + if moduleManagerGID <= 0 { + return nil + } + _, err := Upsert(ctx, RegistryModel("sys.access"), map[string]interface{}{ + "name": "access_sys_module_module_manager", + "model": "sys.module", + "group_id": NullableGroupIDForAccess(moduleManagerGID), + "perm_read": true, + "perm_write": true, + "perm_create": false, + "perm_unlink": false, + }, "name") + if err != nil { + return fmt.Errorf("bootstrap sys.module ACL for module manager: %w", err) + } + return nil +} + func ensureBootstrapSecurity(ctx context.Context, first *SetupAdminParams) error { ctx = AuditedBypass(ctx, "setup.seed") if DB == nil { @@ -174,7 +212,7 @@ func ensureBootstrapSecurity(ctx context.Context, first *SetupAdminParams) error if err != nil { return fmt.Errorf("bootstrap administrator: %w", err) } - if adminUID == 0 { + if 0 == adminUID { return fmt.Errorf("bootstrap administrator user id") } hash, err := bcrypt.GenerateFromPassword([]byte(first.Password), bcrypt.DefaultCost) @@ -195,7 +233,12 @@ func ensureBootstrapSecurity(ctx context.Context, first *SetupAdminParams) error if _, err := DB.ExecContext(ctx, `INSERT INTO `+MustQuotedTableName(tableGroupUserRel)+` (user_id, group_id) VALUES ($1, $2) ON CONFLICT (user_id, group_id) DO NOTHING`, adminUID, userGID); err != nil { return err } - _, _ = DB.ExecContext(ctx, `UPDATE `+userTbl+` SET company_id = $1 WHERE id = $2`, compID, adminUID) + if _, err := DB.ExecContext(ctx, `UPDATE `+userTbl+` SET company_id = $1 WHERE id = $2`, compID, adminUID); err != nil { + return fmt.Errorf("set administrator company: %w", err) + } + if err := SetUserCompanyLinks(ctx, adminUID, []int{compID}); err != nil { + return err + } ensureBootstrapACLs(ctx, adminGID, userGID) ensurePlatformDefaults(ctx) diff --git a/core/orm/setup_sync.go b/core/orm/setup_sync.go index d07428a0..4cc3c71b 100644 --- a/core/orm/setup_sync.go +++ b/core/orm/setup_sync.go @@ -73,7 +73,7 @@ func SyncRegistrySchemaForNames(modelNames []string) error { func ModelsForModuleSchemaSync(moduleName string) ([]string, bool) { owned := ModelsOwnedByModule(moduleName) extended := ModelsExtendedByModule(moduleName) - if len(owned) == 0 && len(extended) == 0 { + if 0 == len(owned) && 0 == len(extended) { return nil, false } seen := make(map[string]struct{}, len(owned)+len(extended)) diff --git a/core/orm/sql_ident.go b/core/orm/sql_ident.go index fa1732f7..31eab349 100644 --- a/core/orm/sql_ident.go +++ b/core/orm/sql_ident.go @@ -71,7 +71,7 @@ func ParseOrderByForModel(modelName, orderBy string) (string, error) { return col + " ASC", nil } parts := strings.Fields(ob) - if len(parts) == 0 || len(parts) > 2 { + if 0 == len(parts) || len(parts) > 2 { return "", fmt.Errorf("invalid order by %q", orderBy) } field := parts[0] diff --git a/core/orm/sys_bus_event.go b/core/orm/sys_bus_event.go index b7c613f9..de8ec76c 100644 --- a/core/orm/sys_bus_event.go +++ b/core/orm/sys_bus_event.go @@ -55,7 +55,7 @@ func InsertBusEvent(ctx context.Context, channel string, payload map[string]inte // LoadBusEventByID loads channel and payload for a persisted event id. func LoadBusEventByID(ctx context.Context, eventID int64) (channel string, payload map[string]interface{}, err error) { - if DB == nil || eventID <= 0 { + if DB == nil || 0 >= eventID { return "", nil, fmt.Errorf("invalid bus event load") } tbl, ok := busEventTable() @@ -77,14 +77,14 @@ func LoadBusEventByID(ctx context.Context, eventID int64) (channel string, paylo // ListBusEventsAfter returns events for channels with id > afterID (cap limit). func ListBusEventsAfter(ctx context.Context, channels []string, afterID int64, limit int) ([]BusEventRow, error) { - if DB == nil || len(channels) == 0 { + if DB == nil || 0 == len(channels) { return nil, nil } tbl, ok := busEventTable() if !ok { return nil, nil } - if limit <= 0 || limit > 500 { + if 0 >= limit || limit > 500 { limit = 500 } // Build IN clause for channels — ponytail: small channel list per subscribe frame. diff --git a/core/orm/sys_field.go b/core/orm/sys_field.go index ab1f33cb..51cc5b76 100644 --- a/core/orm/sys_field.go +++ b/core/orm/sys_field.go @@ -16,5 +16,5 @@ type SysField struct { Required modelmeta.Boolean Readonly modelmeta.Boolean Index modelmeta.Boolean - Manual modelmeta.Boolean `sumeru:"string=Manual field"` + Manual modelmeta.Boolean `sumeru:"string=Manual field"` } diff --git a/core/orm/sys_module.go b/core/orm/sys_module.go index fcc5ef4b..aceba970 100644 --- a/core/orm/sys_module.go +++ b/core/orm/sys_module.go @@ -14,8 +14,8 @@ type SysModule struct { Description modelmeta.Text Icon modelmeta.String CategoryID modelmeta.Many2One[SysModuleCategory] `sumeru:"relation=sys.module.category"` - State modelmeta.String `sumeru:"required"` - Application modelmeta.Boolean `sumeru:"required"` - Active modelmeta.Boolean `sumeru:"required"` + State modelmeta.String `sumeru:"required"` + Application modelmeta.Boolean `sumeru:"required"` + Active modelmeta.Boolean `sumeru:"required"` LastError modelmeta.Text } diff --git a/core/orm/testexports.go b/core/orm/testexports.go index 26730859..e5d5d19c 100644 --- a/core/orm/testexports.go +++ b/core/orm/testexports.go @@ -18,6 +18,11 @@ func NewStubModelForTest(name string, fields []FieldDefinition) StubModelForTest } // RegisterStubModelForTest registers a minimal model for external tests. +// StampActiveCompanyOnCreateForTest applies create-time company_id defaulting for tests. +func StampActiveCompanyOnCreateForTest(ctx context.Context, modelName string, prepared map[string]interface{}) { + stampActiveCompanyOnCreate(ctx, modelName, prepared) +} + func RegisterStubModelForTest(t *testing.T, name string, fields []FieldDefinition) StubModelForTest { t.Helper() m := StubModelForTest{name: name, fields: fields} diff --git a/core/orm/user_active_company.go b/core/orm/user_active_company.go new file mode 100644 index 00000000..e4a63058 --- /dev/null +++ b/core/orm/user_active_company.go @@ -0,0 +1,16 @@ +package orm + +import ( + "context" + "fmt" +) + +// SetUserActiveCompany updates core.user.company_id through the ORM write pipeline. +func SetUserActiveCompany(ctx context.Context, userID, companyID int) error { + if userID <= 0 || companyID <= 0 { + return fmt.Errorf("invalid user or company") + } + return UpdateRecordByID(ctx, "core.user", userID, map[string]interface{}{ + "company_id": companyID, + }) +} diff --git a/core/orm/user_auth.go b/core/orm/user_auth.go new file mode 100644 index 00000000..c24de8cc --- /dev/null +++ b/core/orm/user_auth.go @@ -0,0 +1,153 @@ +package orm + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +var ( + // ErrInvalidCredentials is returned when login or password does not match. + ErrInvalidCredentials = errors.New("invalid credentials") + // ErrUserInactive is returned when the user record is inactive. + ErrUserInactive = errors.New("user inactive") +) + +const defaultSessionHours = 24 + +var loginDummyHash = mustLoginDummyBcryptHash() + +func mustLoginDummyBcryptHash() string { + h, err := bcrypt.GenerateFromPassword([]byte("dummy"), bcrypt.DefaultCost) + if err != nil { + panic(err) + } + return string(h) +} + +// AuthenticateLocalUser verifies login/password via ORM read on core.user. +func AuthenticateLocalUser(ctx context.Context, login, password string) (uid int, username, name string, err error) { + login = strings.TrimSpace(login) + if login == "" || password == "" { + return 0, "", "", ErrInvalidCredentials + } + if DB == nil { + comparePasswordHash("", password) + return 0, "", "", ErrInvalidCredentials + } + ctx = AuditedBypass(ctx, "auth.local") + rows, err := SearchPage(ctx, "core.user", [][]interface{}{{"login", "ilike", login}}, 5, 0, "") + if err != nil { + return 0, "", "", err + } + var match map[string]interface{} + for _, row := range rows { + l, _ := row["login"].(string) + if strings.EqualFold(strings.TrimSpace(l), login) { + match = row + break + } + } + if match == nil { + comparePasswordHash("", password) + return 0, "", "", ErrInvalidCredentials + } + id64, ok := CoerceInt64(match["id"]) + if !ok || id64 <= 0 { + return 0, "", "", ErrInvalidCredentials + } + id := int(id64) + active, _ := match["active"].(bool) + if !active { + comparePasswordHash("", password) + return 0, "", "", ErrInvalidCredentials + } + hash, _ := match["password"].(string) + if !comparePasswordHash(hash, password) { + return 0, "", "", ErrInvalidCredentials + } + username, _ = match["login"].(string) + name, _ = match["name"].(string) + if strings.TrimSpace(name) == "" { + name = username + } + return id, username, name, nil +} + +// SessionCreateOpts controls optional sys.session fields at create time. +type SessionCreateOpts struct { + StampBrowserSeen bool +} + +// CreateUserSession inserts sys.session via ORM Create and returns the sid. +func CreateUserSession(ctx context.Context, userID int, opts SessionCreateOpts) (sessionID string, err error) { + if userID <= 0 { + return "", fmt.Errorf("user id required") + } + inst, ok := Registry["sys.session"] + if !ok { + return "", fmt.Errorf("unknown model %q", "sys.session") + } + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", err + } + sessionID = hex.EncodeToString(buf) + expiresAt := time.Now().UTC().Add(defaultSessionHours * time.Hour).Format(time.RFC3339) + vals := map[string]interface{}{ + "sid": sessionID, + "user_id": userID, + "expires_at": expiresAt, + } + if opts.StampBrowserSeen { + vals["browser_seen_at"] = time.Now().UTC().Format(time.RFC3339) + } + ctx = AuditedBypass(ctx, "session.create") + if _, err := Create(ctx, inst, vals); err != nil { + return "", err + } + return sessionID, nil +} + +// IssueAPIKeyForLogin mints a one-time API key for auth.login responses. +func IssueAPIKeyForLogin(ctx context.Context, userID int) (raw string, err error) { + return CreateAPIKeyForUser(ctx, userID, "auth.login") +} + +// ResolveCompanyTenant resolves X-Sumeru-Tenant to a company id (numeric or name). +func ResolveCompanyTenant(ctx context.Context, tenantHeader string) (companyID int64, err error) { + tenantHeader = strings.TrimSpace(tenantHeader) + if tenantHeader == "" { + return 0, fmt.Errorf("tenant required") + } + if id, parseErr := strconv.ParseInt(tenantHeader, 10, 64); parseErr == nil && id > 0 { + return id, nil + } + ctx = AuditedBypass(ctx, "tenant.resolve") + row, err := SearchOne(ctx, "core.company", map[string]interface{}{"name": tenantHeader}) + if err != nil { + return 0, fmt.Errorf("unknown tenant %q", tenantHeader) + } + cid, ok := CoerceInt64(row["id"]) + if !ok || cid <= 0 { + return 0, fmt.Errorf("unknown tenant %q", tenantHeader) + } + return cid, nil +} + +func comparePasswordHash(storedHash, plain string) bool { + storedHash = strings.TrimSpace(storedHash) + if storedHash == "" { + _ = bcrypt.CompareHashAndPassword([]byte(loginDummyHash), []byte(plain)) + return false + } + return bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(plain)) == nil +} + diff --git a/core/orm/user_password.go b/core/orm/user_password.go index 2791867c..01f89743 100644 --- a/core/orm/user_password.go +++ b/core/orm/user_password.go @@ -25,7 +25,7 @@ func passwordHashWriteAllowed(ctx context.Context) bool { // SetOwnUserPassword lets a user change their own password (no current-password check yet). func SetOwnUserPassword(ctx context.Context, actor, userID int, plain string) error { - if userID <= 0 || actor <= 0 { + if 0 >= userID || 0 >= actor { return fmt.Errorf("unauthenticated") } if actor != userID && !UserHasGroupXML(ctx, actor, "base.group_system") { @@ -50,10 +50,10 @@ func SetOwnUserPassword(ctx context.Context, actor, userID int, plain string) er // SetUserPassword hashes plain under policy and stores it. Requires system admin. func SetUserPassword(ctx context.Context, actor, userID int, plain string) error { - if userID <= 0 { + if 0 >= userID { return fmt.Errorf("invalid user id") } - if actor <= 0 { + if 0 >= actor { return fmt.Errorf("unauthenticated") } if !UserHasGroupXML(ctx, actor, "base.group_system") { @@ -77,7 +77,7 @@ func SetUserPassword(ctx context.Context, actor, userID int, plain string) error // SetUserPasswordHash stores a pre-computed bcrypt hash (internal / trusted callers only). func SetUserPasswordHash(ctx context.Context, userID int, hash string) error { - if userID <= 0 { + if 0 >= userID { return fmt.Errorf("invalid user id") } hash = strings.TrimSpace(hash) @@ -93,7 +93,7 @@ func SetUserPasswordHash(ctx context.Context, userID int, hash string) error { } func DestroySessionsForUser(ctx context.Context, userID int) { - if DB == nil || userID <= 0 { + if DB == nil || 0 >= userID { return } tbl := MustQuotedTableName("sys.session") diff --git a/core/orm/user_security_fields.go b/core/orm/user_security_fields.go index 50da2ed0..bea1b63c 100644 --- a/core/orm/user_security_fields.go +++ b/core/orm/user_security_fields.go @@ -7,7 +7,7 @@ import ( // RejectCoreUserSecurityWrites blocks mass-assignment of security-sensitive core.user columns. func RejectCoreUserSecurityWrites(ctx context.Context, uid int, values map[string]interface{}) error { - if len(values) == 0 { + if 0 == len(values) { return nil } if SecurityBypass(ctx) || uid == superuserUID { diff --git a/core/orm/user_security_meta.go b/core/orm/user_security_meta.go index 05de8611..684051cb 100644 --- a/core/orm/user_security_meta.go +++ b/core/orm/user_security_meta.go @@ -7,10 +7,10 @@ import ( // UserSecurityGroupRow is one assignable group for the user form security section. type UserSecurityGroupRow struct { - ID int `json:"id"` - Name string `json:"name"` - Selected bool `json:"selected"` - IsUserType bool `json:"isUserType"` + ID int `json:"id"` + Name string `json:"name"` + Selected bool `json:"selected"` + IsUserType bool `json:"isUserType"` } // UserSecurityCompanyRow is one company checkbox for allowed companies. @@ -34,7 +34,7 @@ func BuildUserSecurityMeta(ctx context.Context, actor, targetUserID int) (UserSe out := UserSecurityMeta{ PasswordLabel: "Change password", } - if targetUserID <= 0 { + if 0 >= targetUserID { out.IsNew = true out.PasswordLabel = "Set initial password" } @@ -78,7 +78,7 @@ func BuildUserSecurityMeta(ctx context.Context, actor, targetUserID int) (UserSe } for _, row := range groupRows { id := intFromAny(row["id"]) - if id <= 0 { + if 0 >= id { continue } _, isType := typeIDSet[id] @@ -108,7 +108,7 @@ func BuildUserSecurityMeta(ctx context.Context, actor, targetUserID int) (UserSe } for _, row := range companyRows { id := intFromAny(row["id"]) - if id <= 0 { + if 0 >= id { continue } _, sel := selectedCompanies[id] diff --git a/core/orm/user_security_post.go b/core/orm/user_security_post.go index f93e0a4f..cf30b5cc 100644 --- a/core/orm/user_security_post.go +++ b/core/orm/user_security_post.go @@ -18,7 +18,7 @@ func ApplyUserSecurityPost(ctx context.Context, actor, userID int, form url.Valu // ApplyUserSecurityPostErr is like ApplyUserSecurityPost but returns the first hard error. func ApplyUserSecurityPostErr(ctx context.Context, actor, userID int, form url.Values) error { - if userID <= 0 { + if 0 >= userID { return fmt.Errorf("invalid user id") } if err := CheckModelAccess(ctx, actor, "core.user", "write"); err != nil { diff --git a/core/orm/user_totp.go b/core/orm/user_totp.go index 732b8624..73354331 100644 --- a/core/orm/user_totp.go +++ b/core/orm/user_totp.go @@ -62,10 +62,10 @@ func DisableOwnTOTP(ctx context.Context, actor, userID int, code string) error { // DisableUserTOTP clears TOTP for userID (system administrator). func DisableUserTOTP(ctx context.Context, actor, userID int) error { - if userID <= 0 { + if 0 >= userID { return fmt.Errorf("invalid user id") } - if actor <= 0 { + if 0 >= actor { return fmt.Errorf("unauthenticated") } if !UserHasGroupXML(ctx, actor, "base.group_system") { @@ -105,7 +105,7 @@ func OwnTOTPEnrollmentPending(ctx context.Context, actor, userID int) (secret st } func assertOwnUser(actor, userID int) error { - if userID <= 0 || actor <= 0 { + if 0 >= userID || 0 >= actor { return fmt.Errorf("unauthenticated") } if actor != userID { diff --git a/core/orm/values_prepare.go b/core/orm/values_prepare.go index 90e4b56a..e197a816 100644 --- a/core/orm/values_prepare.go +++ b/core/orm/values_prepare.go @@ -112,6 +112,29 @@ func PrepareValues(model Model, values map[string]interface{}, op WriteOp, opts return out, nil } +// stampActiveCompanyOnCreate sets company_id from the active company when the model has that column and the client omitted it. +func stampActiveCompanyOnCreate(ctx context.Context, modelName string, prepared map[string]interface{}) { + if !ModelHasCompanyID(modelName) { + return + } + if v, ok := prepared["company_id"]; ok && v != nil { + if id, ok := CoerceInt64(v); ok && id > 0 { + return + } + } + uid := SecurityUID(ctx) + if uid <= 0 { + return + } + companyID := CompanyIDFromContext(ctx) + if companyID <= 0 { + companyID = ActiveCompanyIDForUser(ctx, uid) + } + if companyID > 0 { + prepared["company_id"] = int(companyID) + } +} + func parseFloatFieldValue(raw interface{}) (float64, error) { switch typed := raw.(type) { case float64: @@ -186,7 +209,7 @@ func coerceFieldValue(fieldDef FieldDefinition, v interface{}) (interface{}, err if err != nil { return nil, err } - if parsed == 0 { + if 0 == parsed { if s, ok := v.(string); ok && strings.TrimSpace(s) == "" { return nil, nil } @@ -200,7 +223,7 @@ func coerceFieldValue(fieldDef FieldDefinition, v interface{}) (interface{}, err } return "", nil } - if len(fieldDef.Selection) == 0 { + if 0 == len(fieldDef.Selection) { return s, nil } for _, opt := range fieldDef.Selection { diff --git a/core/orm/workflow_check.go b/core/orm/workflow_check.go index 65a3f764..0adc1aad 100644 --- a/core/orm/workflow_check.go +++ b/core/orm/workflow_check.go @@ -81,7 +81,7 @@ func checkWorkflowTransitionRows(ctx context.Context, model, fromState, toState break } } - if n == 0 { + if 0 == n { return errNoWorkflowRows } if !matchedFrom || !allowed { diff --git a/core/orm/x2m_commands.go b/core/orm/x2m_commands.go index 03a0ada3..55412365 100644 --- a/core/orm/x2m_commands.go +++ b/core/orm/x2m_commands.go @@ -39,7 +39,7 @@ func parseOneX2MCommand(item interface{}) (X2MCommand, error) { if !ok { return X2MCommand{}, fmt.Errorf("x2m: expected command tuple, got %T", item) } - if len(tuple) == 0 { + if 0 == len(tuple) { return X2MCommand{}, fmt.Errorf("x2m: empty command tuple") } op, ok := asInt64(tuple[0]) diff --git a/core/report/bulk_dryrun.go b/core/report/bulk_dryrun.go index 2abe6b80..1745d25c 100644 --- a/core/report/bulk_dryrun.go +++ b/core/report/bulk_dryrun.go @@ -31,7 +31,7 @@ func DryRunBulkImport(ctx context.Context, in PreviewBulkImportInput) (PreviewRe return PreviewResult{}, nil, err } mapping := in.Mapping - if len(mapping) == 0 { + if 0 == len(mapping) { _ = json.Unmarshal([]byte(orm.AsString(batch["column_mapping"])), &mapping) } mode := orm.AsString(batch["import_mode"]) @@ -42,7 +42,7 @@ func DryRunBulkImport(ctx context.Context, in PreviewBulkImportInput) (PreviewRe for i, record := range rows { vals := rowValuesFromMapping(headers, record, mapping) - if len(vals) == 0 { + if 0 == len(vals) { continue } coerced, coerceErrs := coerceImportValues(ctx, modelInst, vals) diff --git a/core/report/bulk_import.go b/core/report/bulk_import.go index 6d7fd02f..d4d0fa50 100644 --- a/core/report/bulk_import.go +++ b/core/report/bulk_import.go @@ -11,9 +11,9 @@ import ( // ExecuteBulkImportInput runs import after user confirms mapping. type ExecuteBulkImportInput struct { - BatchID int - Mapping map[string]string - SkipInvalid bool + BatchID int + Mapping map[string]string + SkipInvalid bool ImportValidOnly bool } @@ -30,22 +30,23 @@ func ExecuteBulkImport(ctx context.Context, in ExecuteBulkImportInput) (ImportRe return ImportResult{}, fmt.Errorf("unknown model %q", targetModel) } mapping := in.Mapping - if len(mapping) == 0 { + if 0 == len(mapping) { mapping = storedMapping } headers, rows, err := parseCSV(data) if err != nil { return ImportResult{}, err } + queuedUserCtx := bulkActorContext(batch) allowed := allowedFieldNames(modelInst) var result ImportResult for i, record := range rows { vals := rowValuesFromMapping(headers, record, mapping) - if len(vals) == 0 { + if 0 == len(vals) { result.Skipped++ continue } - coerced, coerceErrs := coerceImportValues(ctx, modelInst, vals) + coerced, coerceErrs := coerceImportValues(queuedUserCtx, modelInst, vals) vals = coerced errs := append([]string{}, coerceErrs...) errs = append(errs, validateRowValues(modelInst, vals, allowed, mode)...) @@ -66,7 +67,7 @@ func ExecuteBulkImport(ctx context.Context, in ExecuteBulkImportInput) (ImportRe } } if len(updateVals) > 0 { - if err := orm.UpdateRecordByID(ctx, targetModel, int(id), updateVals); err != nil { + if err := orm.UpdateRecordByID(queuedUserCtx, targetModel, int(id), updateVals); err != nil { if in.SkipInvalid { result.Skipped++ continue @@ -80,7 +81,7 @@ func ExecuteBulkImport(ctx context.Context, in ExecuteBulkImportInput) (ImportRe } } delete(vals, "id") - if _, err := orm.Create(ctx, modelInst, vals); err != nil { + if _, err := orm.Create(queuedUserCtx, modelInst, vals); err != nil { if in.SkipInvalid { result.Skipped++ continue diff --git a/core/report/bulk_job.go b/core/report/bulk_job.go index a0212e76..97ca9b91 100644 --- a/core/report/bulk_job.go +++ b/core/report/bulk_job.go @@ -21,11 +21,22 @@ func RunBulkJobCronForTest(ctx context.Context) error { func runBulkJobCron(ctx context.Context, _ map[string]interface{}) error { batch, err := orm.SearchOne(ctx, BulkModelName, map[string]interface{}{"state": BulkStateQueued}) - if err != nil || len(batch) == 0 { + if err != nil { + return nil + } + if 0 == len(batch) { return nil } id, _ := orm.CoerceInt64(batch["id"]) - if id <= 0 { + if 0 >= id { + return nil + } + queuedUserID, _ := orm.CoerceInt64(batch["user_id"]) + if 0 >= queuedUserID { + _ = orm.UpdateRecordByID(ctx, BulkModelName, int(id), map[string]interface{}{ + "state": BulkStateFailed, + "result_summary": "Missing user for queued job", + }) return nil } _ = orm.UpdateRecordByID(ctx, BulkModelName, int(id), map[string]interface{}{"state": BulkStateRunning}) @@ -72,17 +83,18 @@ func runExportJob(ctx context.Context, batch map[string]interface{}) error { _ = json.Unmarshal([]byte(orm.AsString(batch["export_domain"])), &domain) fields := ParseFieldsParam(orm.AsString(batch["selected_fields"])) in := ExportCSVInput{Model: modelName, Fields: fields, Domain: domain} + queuedUserCtx := bulkActorContext(batch) var data []byte var err error - data, err = ExportCSVUnlimited(ctx, in) + data, err = ExportCSVUnlimited(queuedUserCtx, in) if format == "xlsx" { fields, _ := ValidateFields(modelName, fields) - rows, _ := FetchRowsUnlimited(ctx, modelName, domain, 0) + rows, _ := FetchRowsUnlimited(queuedUserCtx, modelName, domain, 0) var table [][]string for _, row := range rows { line := make([]string, len(fields)) for i, f := range fields { - line[i] = formatCell(ctx, modelName, f, row[f]) + line[i] = formatCell(queuedUserCtx, modelName, f, row[f]) } table = append(table, line) } @@ -110,6 +122,21 @@ func runExportJob(ctx context.Context, batch map[string]interface{}) error { }) } +// bulkActorContext is the queued user, without the scheduler bypass flag. +func bulkActorContext(batch map[string]interface{}) context.Context { + uid64, _ := orm.CoerceInt64(batch["user_id"]) + ctx := orm.ContextWithUID(context.Background(), int(uid64)) + if cid := orm.ActiveCompanyIDForUser(ctx, int(uid64)); cid > 0 { + ctx = orm.ContextWithCompanyID(ctx, cid) + } + return ctx +} + +// BulkJobActorContextForTest exposes the queued-user context for external tests. +func BulkJobActorContextForTest(batch map[string]interface{}) context.Context { + return bulkActorContext(batch) +} + func exportMime(format string) string { if format == "xlsx" { return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" diff --git a/core/report/bulk_preview.go b/core/report/bulk_preview.go index a39c8dd0..03ed34f9 100644 --- a/core/report/bulk_preview.go +++ b/core/report/bulk_preview.go @@ -31,7 +31,7 @@ func PreviewBulkImport(ctx context.Context, in PreviewBulkImportInput) (PreviewR return PreviewResult{}, err } mapping := in.Mapping - if len(mapping) == 0 { + if 0 == len(mapping) { _ = json.Unmarshal([]byte(orm.AsString(batch["column_mapping"])), &mapping) } allowed := allowedFieldNames(modelInst) @@ -62,7 +62,7 @@ func PreviewBulkImport(ctx context.Context, in PreviewBulkImportInput) (PreviewR func validateRowValues(modelInst orm.Model, vals map[string]interface{}, allowed map[string]struct{}, mode string) []string { var errs []string - if len(vals) == 0 { + if 0 == len(vals) { return []string{"no mapped values"} } for k := range vals { @@ -72,7 +72,7 @@ func validateRowValues(modelInst orm.Model, vals map[string]interface{}, allowed } if mode == ImportModeUpsert { if idRaw, ok := vals["id"]; ok { - if id, ok := orm.CoerceInt64(idRaw); !ok || id <= 0 { + if id, ok := orm.CoerceInt64(idRaw); !ok || 0 >= id { errs = append(errs, "invalid id for upsert") } } diff --git a/core/report/bulk_staging.go b/core/report/bulk_staging.go index baf90621..1f20b19a 100644 --- a/core/report/bulk_staging.go +++ b/core/report/bulk_staging.go @@ -57,18 +57,18 @@ func CreateBatch(ctx context.Context, in CreateBatchInput) (batchID int, err err mappingJSON, _ := json.Marshal(mapping) batchID, err = orm.Create(ctx, orm.Registry[BulkModelName], map[string]interface{}{ - "name": fmt.Sprintf("Import %s", in.TargetModel), - "target_model": in.TargetModel, - "import_mode": mode, - "attachment_id": attID, - "selected_fields": string(fieldsJSON), - "csv_headers": string(headersJSON), - "column_mapping": string(mappingJSON), - "next_url": in.NextURL, - "user_id": in.UserID, - "action_id": in.ActionID, - "state": BulkStateDraft, - "direction": DirectionImport, + "name": fmt.Sprintf("Import %s", in.TargetModel), + "target_model": in.TargetModel, + "import_mode": mode, + "attachment_id": attID, + "selected_fields": string(fieldsJSON), + "csv_headers": string(headersJSON), + "column_mapping": string(mappingJSON), + "next_url": in.NextURL, + "user_id": in.UserID, + "action_id": in.ActionID, + "state": BulkStateDraft, + "direction": DirectionImport, }) if err != nil { return 0, err @@ -106,7 +106,7 @@ func loadBatchCSV(ctx context.Context, batchID int) (map[string]interface{}, []b return nil, nil, nil, fmt.Errorf("batch not found") } attID, _ := orm.CoerceInt64(batch["attachment_id"]) - if attID <= 0 { + if 0 >= attID { return nil, nil, nil, fmt.Errorf("missing attachment") } att, err := orm.SearchOne(ctx, "sys.attachment", map[string]interface{}{"id": attID}) diff --git a/core/report/capabilities.go b/core/report/capabilities.go index 9af6d25b..978e248c 100644 --- a/core/report/capabilities.go +++ b/core/report/capabilities.go @@ -65,10 +65,10 @@ func mergeCaps(caps *Capabilities, download, upload, pdfSizes, modes string) { } func normalizeCaps(caps *Capabilities) { - if caps.BulkUpload && len(caps.BulkModes) == 0 { + if caps.BulkUpload && 0 == len(caps.BulkModes) { caps.BulkModes = []string{ImportModeCreate, ImportModeUpsert} } - if caps.HasDownload() && len(caps.PDFSizes) == 0 { + if caps.HasDownload() && 0 == len(caps.PDFSizes) { caps.PDFSizes = []string{PageSizeA4, PageSizeLegal, PageSizeLetter} } } @@ -120,4 +120,3 @@ func appendUnique(list []string, items ...string) []string { } return list } - diff --git a/core/report/document_arch.go b/core/report/document_arch.go index 24e25ef4..13e4d441 100644 --- a/core/report/document_arch.go +++ b/core/report/document_arch.go @@ -163,14 +163,14 @@ func TemplatePDFInputFromDocumentArch(arch string, ctx DocumentRenderContext, pa out.TableRows = append(out.TableRows, rows...) } } - if len(out.Sections) == 0 && len(out.TableRows) == 0 && subtitle == "" { + if 0 == len(out.Sections) && 0 == len(out.TableRows) && subtitle == "" { return TemplatePDFInput{}, fmt.Errorf("report arch produced no content") } return out, nil } func tableFromDocument(tbl documentTable, ctx DocumentRenderContext) ([]string, [][]string) { - if len(tbl.Rows) == 0 { + if 0 == len(tbl.Rows) { return nil, nil } var head []string @@ -184,7 +184,7 @@ func tableFromDocument(tbl documentTable, ctx DocumentRenderContext) ([]string, } line[j] = rendered } - if i == 0 { + if 0 == i { head = line } else { rows = append(rows, line) diff --git a/core/report/document_html.go b/core/report/document_html.go index 5757affa..31d34c9e 100644 --- a/core/report/document_html.go +++ b/core/report/document_html.go @@ -4,9 +4,9 @@ import ( "bytes" "fmt" "html" + "html/template" "regexp" "strings" - "text/template" ) var htmlTemplates = map[string]string{} @@ -22,19 +22,27 @@ func RegisterHTMLTemplate(name, body string) { // RenderHTMLTemplate executes a registered HTML template into PDF input. func RenderHTMLTemplate(name string, ctx DocumentRenderContext) (TemplatePDFInput, error) { + body, err := executeRegisteredHTML(name, ctx) + if err != nil { + return TemplatePDFInput{}, err + } + return TemplatePDFInputFromHTML(body, PageSizeA4) +} + +func executeRegisteredHTML(name string, ctx DocumentRenderContext) (string, error) { raw, ok := htmlTemplates[strings.TrimSpace(name)] if !ok { - return TemplatePDFInput{}, fmt.Errorf("report html template %q not found", name) + return "", fmt.Errorf("report html template %q not found", name) } tmpl, err := template.New("htmlreport").Option("missingkey=zero").Parse(raw) if err != nil { - return TemplatePDFInput{}, fmt.Errorf("html template parse: %w", err) + return "", fmt.Errorf("html template parse: %w", err) } var buf bytes.Buffer if err := tmpl.Execute(&buf, ctx); err != nil { - return TemplatePDFInput{}, fmt.Errorf("html template exec: %w", err) + return "", fmt.Errorf("html template exec: %w", err) } - return TemplatePDFInputFromHTML(buf.String(), PageSizeA4) + return buf.String(), nil } // TemplatePDFInputFromHTML maps a minimal HTML subset to structured PDF sections. @@ -82,10 +90,10 @@ func TemplatePDFInputFromHTML(htmlBody string, pageSize string) (TemplatePDFInpu for _, cm := range cellRe.FindAllStringSubmatch(rm[1], -1) { cells = append(cells, stripHTMLTags(cm[1])) } - if len(cells) == 0 { + if 0 == len(cells) { continue } - if i == 0 { + if 0 == i { out.TableHead = cells } else { rows = append(rows, cells) @@ -93,7 +101,7 @@ func TemplatePDFInputFromHTML(htmlBody string, pageSize string) (TemplatePDFInpu } out.TableRows = rows } - if len(out.Sections) == 0 && len(out.TableRows) == 0 { + if 0 == len(out.Sections) && 0 == len(out.TableRows) { out.Sections = append(out.Sections, TemplatePDFSection{Body: stripHTMLTags(htmlBody)}) } return out, nil @@ -104,21 +112,12 @@ func stripHTMLTags(s string) string { return strings.TrimSpace(html.UnescapeString(s)) } -// PreviewHTMLFromTemplate renders registered HTML for admin preview (escaped wrapper). +// PreviewHTMLFromTemplate renders a registered HTML template for admin preview. func PreviewHTMLFromTemplate(name string, ctx DocumentRenderContext) (string, error) { - raw, ok := htmlTemplates[strings.TrimSpace(name)] - if !ok { - return "", fmt.Errorf("report html template %q not found", name) - } - tmpl, err := template.New("htmlreport").Option("missingkey=zero").Parse(raw) + body, err := executeRegisteredHTML(name, ctx) if err != nil { return "", err } - var body bytes.Buffer - if err := tmpl.Execute(&body, ctx); err != nil { - return "", err - } - // Serve template output inside a safe shell; dynamic text in template should use html/template auto-escape. return fmt.Sprintf("%s%s", - html.EscapeString(name), body.String()), nil + html.EscapeString(name), body), nil } diff --git a/core/report/document_render.go b/core/report/document_render.go index 2634362b..dd23db8a 100644 --- a/core/report/document_render.go +++ b/core/report/document_render.go @@ -108,10 +108,10 @@ func loadDocumentContext(ctx context.Context, actionRow map[string]interface{}, // StoreReportPDFAttachment saves PDF to filestore and creates sys.attachment linked to record. func StoreReportPDFAttachment(ctx context.Context, modelName string, recordID int, filename string, pdf []byte) (int, error) { - if recordID <= 0 { + if 0 >= recordID { return 0, fmt.Errorf("id required to store attachment") } - if len(pdf) == 0 { + if 0 == len(pdf) { return 0, fmt.Errorf("empty pdf") } name := strings.TrimSpace(filename) diff --git a/core/report/exchange.go b/core/report/exchange.go index 42da2ed3..04b487a0 100644 --- a/core/report/exchange.go +++ b/core/report/exchange.go @@ -50,7 +50,7 @@ func FetchRowsUnlimited(ctx context.Context, modelName string, domain [][]interf if err != nil { return nil, err } - if len(chunk) == 0 { + if 0 == len(chunk) { break } uid := orm.SecurityUID(ctx) @@ -82,7 +82,7 @@ func ValidateFields(modelName string, fields []string) ([]string, error) { } out = append(out, f) } - if len(out) == 0 { + if 0 == len(out) { return nil, fmt.Errorf("no fields selected") } return out, nil diff --git a/core/report/export_analytics.go b/core/report/export_analytics.go index b54c6112..38b35b1e 100644 --- a/core/report/export_analytics.go +++ b/core/report/export_analytics.go @@ -31,7 +31,7 @@ func RenderReportActionPDF(ctx context.Context, reportID, recordID int, opts Rep } func loadReportAction(ctx context.Context, reportID int) (map[string]interface{}, error) { - if reportID <= 0 { + if 0 >= reportID { return nil, fmt.Errorf("report id required") } row, err := orm.SearchOne(ctx, "sys.report.action", map[string]interface{}{"id": reportID}) diff --git a/core/report/export_pdf.go b/core/report/export_pdf.go index f2215c5f..059b7130 100644 --- a/core/report/export_pdf.go +++ b/core/report/export_pdf.go @@ -37,7 +37,7 @@ func landscapePage(size document.Size) document.Size { } func equalColumnWidths(n int) []float64 { - if n <= 0 { + if 0 >= n { return nil } width := 100.0 / float64(n) diff --git a/core/report/export_xlsx.go b/core/report/export_xlsx.go index 6343e67a..a84cceda 100644 --- a/core/report/export_xlsx.go +++ b/core/report/export_xlsx.go @@ -58,9 +58,9 @@ func writeMinimalXLSX(header []string, rows [][]string) ([]byte, error) { var buf bytes.Buffer zw := zip.NewWriter(&buf) files := map[string]string{ - "[Content_Types].xml": contentTypesXML, - "_rels/.rels": rootRelsXML, - "xl/workbook.xml": workbookXML, + "[Content_Types].xml": contentTypesXML, + "_rels/.rels": rootRelsXML, + "xl/workbook.xml": workbookXML, "xl/_rels/workbook.xml.rels": workbookRelsXML, "xl/worksheets/sheet1.xml": sheetXML(header, rows), } diff --git a/core/report/import_coerce.go b/core/report/import_coerce.go index 162a911b..8fd46c77 100644 --- a/core/report/import_coerce.go +++ b/core/report/import_coerce.go @@ -120,12 +120,12 @@ func resolveMany2One(ctx context.Context, relation string, raw interface{}) (int } switch v := raw.(type) { case int64: - if v <= 0 { + if 0 >= v { return nil, fmt.Errorf("invalid reference") } return v, nil case int: - if v <= 0 { + if 0 >= v { return nil, fmt.Errorf("invalid reference") } return int64(v), nil @@ -138,9 +138,13 @@ func resolveMany2One(ctx context.Context, relation string, raw interface{}) (int return id, nil } row, err := orm.SearchOne(ctx, relation, map[string]interface{}{"name": s}) - if err != nil || len(row) == 0 { + if err != nil { + return nil, fmt.Errorf("no %s named %q", relation, s) + } + if 0 == len(row) { return nil, fmt.Errorf("no %s named %q", relation, s) } + id, _ := orm.CoerceInt64(row["id"]) return id, nil } diff --git a/core/report/import_spreadsheet.go b/core/report/import_spreadsheet.go index 66c49240..0920b3df 100644 --- a/core/report/import_spreadsheet.go +++ b/core/report/import_spreadsheet.go @@ -45,11 +45,11 @@ func xlsxFirstSheetCSV(content []byte) ([]byte, error) { } } } - if len(sheetXML) == 0 { + if 0 == len(sheetXML) { return nil, fmt.Errorf("xlsx: sheet1 missing") } rows := parseSheetRows(sheetXML, shared) - if len(rows) == 0 { + if 0 == len(rows) { return nil, fmt.Errorf("xlsx: empty sheet") } return writeCSV(rows[0], rows[1:]) diff --git a/core/report/import_template.go b/core/report/import_template.go index 3cead4d5..6cd1adf1 100644 --- a/core/report/import_template.go +++ b/core/report/import_template.go @@ -22,16 +22,20 @@ func ApplyImportTemplateMapping(ctx context.Context, targetModel string, headers domain = append(domain, []interface{}{"user_id", "=", uid}) } rows, err := orm.SearchPage(ctx, importTemplateModel, domain, 5, 0, "id DESC") - if err != nil || len(rows) == 0 { + if err != nil || 0 == len(rows) { domain = [][]interface{}{{"target_model", "=", targetModel}, {"shared", "=", true}} rows, err = orm.SearchPage(ctx, importTemplateModel, domain, 5, 0, "id DESC") } - if err != nil || len(rows) == 0 { + if err != nil { return nil } + if 0 == len(rows) { + return nil + } + var mapping map[string]string _ = json.Unmarshal([]byte(orm.AsString(rows[0]["column_mapping"])), &mapping) - if len(mapping) == 0 { + if 0 == len(mapping) { return nil } out := map[string]string{} diff --git a/core/report/register.go b/core/report/register.go index da525794..9daa56a7 100644 --- a/core/report/register.go +++ b/core/report/register.go @@ -16,7 +16,7 @@ func init() { } func actionConfirmImport(ctx context.Context, model string, id int, vals map[string]string) (string, error) { - if model != BulkModelName || id <= 0 { + if model != BulkModelName || 0 >= id { return "", fmt.Errorf("invalid batch") } batch, err := orm.SearchOne(ctx, BulkModelName, map[string]interface{}{"id": id}) @@ -31,8 +31,8 @@ func actionConfirmImport(ctx context.Context, model string, id int, vals map[str rowCount, _ := CountBatchRows(ctx, id) if rowCount > asyncImportRowThreshold { _ = orm.UpdateRecordByID(ctx, BulkModelName, id, map[string]interface{}{ - "state": BulkStateQueued, - "total_rows": rowCount, + "state": BulkStateQueued, + "total_rows": rowCount, "column_mapping": orm.AsString(batch["column_mapping"]), }) if len(mapping) > 0 { @@ -87,7 +87,7 @@ func BulkImportFormActionID(ctx context.Context) int { // MappingFormURL builds workspace URL for a batch mapping form. func MappingFormURL(batchID, actionID int) string { - if actionID <= 0 { + if 0 >= actionID { return fmt.Sprintf("/web?model=%s&id=%d&view_type=form", BulkModelName, batchID) } return fmt.Sprintf("/web?action=%d&id=%d&view_type=form", actionID, batchID) diff --git a/core/report/scheduled.go b/core/report/scheduled.go index 6cbb037e..73097ab0 100644 --- a/core/report/scheduled.go +++ b/core/report/scheduled.go @@ -46,7 +46,7 @@ func RegisterScheduledReport(spec ScheduledReportSpec) error { } func deliverScheduledReport(ctx context.Context, model string, fields []string, domain [][]interface{}, format string, recipients []string, reportName string) error { - if len(recipients) == 0 { + if 0 == len(recipients) { return fmt.Errorf("scheduled report %q: no recipients", reportName) } in := ExportCSVInput{Model: model, Fields: fields, Domain: domain, Title: reportName} diff --git a/core/report/types.go b/core/report/types.go index e4c47c20..122357c3 100644 --- a/core/report/types.go +++ b/core/report/types.go @@ -14,18 +14,18 @@ const ( DirectionImport = "import" DirectionExport = "export" - BulkStateDraft = "draft" - BulkStateQueued = "queued" - BulkStateRunning = "running" - BulkStateDone = "done" - BulkStateFailed = "failed" + BulkStateDraft = "draft" + BulkStateQueued = "queued" + BulkStateRunning = "running" + BulkStateDone = "done" + BulkStateFailed = "failed" BulkStateCancelled = "cancelled" BulkJobCronCode = "platform.bulk_job" - maxExportRows = 500 - maxSyncExportRows = 500 - maxPreviewRows = 50 + maxExportRows = 500 + maxSyncExportRows = 500 + maxPreviewRows = 50 asyncImportRowThreshold = 500 ) diff --git a/core/scheduler/scheduler.go b/core/scheduler/scheduler.go index 72411a1f..846eb7cd 100644 --- a/core/scheduler/scheduler.go +++ b/core/scheduler/scheduler.go @@ -25,7 +25,7 @@ func Start(parent context.Context, every time.Duration) { if cancel != nil { return } - if every <= 0 { + if 0 >= every { every = time.Minute } ctx, c := context.WithCancel(parent) @@ -146,7 +146,7 @@ func cronIntervalTx(ctx context.Context, tx orm.TxWrapper, id int64) time.Durati return time.Hour } n := int(mins.Int64) - if n <= 0 { + if 0 >= n { n = 60 } return time.Duration(n) * time.Minute diff --git a/core/sdk/convert.go b/core/sdk/convert.go index 78090071..93e161f2 100644 --- a/core/sdk/convert.go +++ b/core/sdk/convert.go @@ -50,7 +50,7 @@ func currencyRate(ctx context.Context, currencyID int, date time.Time) (float64, if err != nil { return 0, fmt.Errorf("sdk: lookup currency rate for currency %d: %w", currencyID, err) } - if len(rows) == 0 { + if 0 == len(rows) { return 0, fmt.Errorf("sdk: no active core.currency.rate for currency %d on %s or earlier", currencyID, date.Format(dateLayout)) } @@ -66,7 +66,7 @@ func currencyRate(ctx context.Context, currencyID int, date time.Time) (float64, if !ok { return 0, fmt.Errorf("sdk: non-numeric rate %v on core.currency.rate row %v", best["rate"], best["id"]) } - if rate <= 0 { + if 0 >= rate { return 0, fmt.Errorf("sdk: invalid non-positive rate %v for currency %d", rate, currencyID) } return rate, nil @@ -110,11 +110,11 @@ func ConvertUom(ctx context.Context, amount float64, fromUomID, toUomID int) (fl fromUomID, fromCat, toUomID, toCat) } factorFrom, ok := asFloat(from["factor"]) - if !ok || factorFrom <= 0 { + if !ok || 0 >= factorFrom { return 0, fmt.Errorf("sdk: invalid factor on uom %d", fromUomID) } factorTo, ok := asFloat(to["factor"]) - if !ok || factorTo <= 0 { + if !ok || 0 >= factorTo { return 0, fmt.Errorf("sdk: invalid factor on uom %d", toUomID) } rounding, _ := asFloat(to["rounding"]) @@ -123,7 +123,7 @@ func ConvertUom(ctx context.Context, amount float64, fromUomID, toUomID int) (fl // ConvertUomByFactor applies the pure factor math of uom conversion: // amount * factorFrom / factorTo, rounded to multiples of rounding (no-op -// when rounding <= 0). Exported so conversions can be unit-tested without a +// when 0 >= rounding). Exported so conversions can be unit-tested without a // database and reused by verticals on preloaded records. func ConvertUomByFactor(amount, factorFrom, factorTo, rounding float64) float64 { out := amount * factorFrom / factorTo @@ -158,7 +158,7 @@ func ConvertMoneyField(ctx context.Context, modelName, fieldName string, rec map return 0, fmt.Errorf("sdk: field %s.%s is not tagged currency=", modelName, fieldName) } fromID, ok := asInt(rec[def.Currency]) - if !ok || fromID == 0 { + if !ok || 0 == fromID { return 0, fmt.Errorf("sdk: record has no %q value for %s.%s", def.Currency, modelName, fieldName) } amount, ok := asFloat(rec[fieldName]) diff --git a/core/sdk/report.go b/core/sdk/report.go index 87ecf7a4..913d3446 100644 --- a/core/sdk/report.go +++ b/core/sdk/report.go @@ -8,9 +8,9 @@ import ( // Report page size constants (re-exported from core/report). const ( - PageSizeA4 = report.PageSizeA4 - PageSizeLegal = report.PageSizeLegal - PageSizeLetter = report.PageSizeLetter + PageSizeA4 = report.PageSizeA4 + PageSizeLegal = report.PageSizeLegal + PageSizeLetter = report.PageSizeLetter ImportModeCreate = report.ImportModeCreate ImportModeUpsert = report.ImportModeUpsert ) diff --git a/core/security/fields.go b/core/security/fields.go index 17763f49..3b953e1a 100644 --- a/core/security/fields.go +++ b/core/security/fields.go @@ -49,7 +49,7 @@ func WriteDenyDirectFields(model string) map[string]bool { func fieldsMatching(model string, pred func(FieldPolicy) bool) map[string]bool { fields := FieldRegistry[model] - if len(fields) == 0 { + if 0 == len(fields) { return nil } out := make(map[string]bool) diff --git a/core/server/api/dispatch.go b/core/server/api/dispatch.go index 88f36ed0..fa30812b 100644 --- a/core/server/api/dispatch.go +++ b/core/server/api/dispatch.go @@ -12,18 +12,18 @@ import ( // PublicMethods is the allowlist of model methods callable via RPC. var PublicMethods = map[string]bool{ - "search": true, - "search_read": true, - "read": true, - "create": true, - "write": true, - "unlink": true, - "create_many": true, - "write_many": true, - "unlink_many": true, - "read_group": true, - "onchange": true, - "call": true, + "search": true, + "search_read": true, + "read": true, + "create": true, + "write": true, + "unlink": true, + "create_many": true, + "write_many": true, + "unlink_many": true, + "read_group": true, + "onchange": true, + "call": true, } // rpcRequest is the JSON body for POST /api/rpc. @@ -38,7 +38,7 @@ type rpcRequest struct { // dispatchRPC executes a model method call with the same security context as the web UI (uid in ctx). func dispatchRPC(ctx context.Context, body []byte) (interface{}, error) { - if len(body) == 0 { + if 0 == len(body) { return nil, newRPCError(CodeInvalidJSON, "empty body", nil) } var in rpcRequest @@ -105,7 +105,7 @@ func dispatchRPC(ctx context.Context, body []byte) (interface{}, error) { } func validateKwargs(raw json.RawMessage) error { - if len(raw) == 0 || string(raw) == "null" { + if 0 == len(raw) || string(raw) == "null" { return nil } var probe interface{} diff --git a/core/server/api/envelope.go b/core/server/api/envelope.go index eddd48d5..03570bfd 100644 --- a/core/server/api/envelope.go +++ b/core/server/api/envelope.go @@ -3,46 +3,133 @@ package api import ( "context" "encoding/json" + "fmt" "net/http" + "time" ) -// RPCError is the structured error object in POST /api/rpc responses. -type RPCError struct { - Code string `json:"code"` - Message string `json:"message"` - Details map[string]interface{} `json:"details,omitempty"` +// APIError is the structured error object in /api/rpc responses. +type APIError struct { + Code string `json:"code,omitempty"` + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Message string `json:"message,omitempty"` + Arguments []any `json:"arguments,omitempty"` + Context map[string]interface{} `json:"context,omitempty"` + Debug any `json:"debug,omitempty"` } -// RPCResponse is the standard envelope for POST /api/rpc. -type RPCResponse struct { - OK bool `json:"ok"` - Result interface{} `json:"result"` - Error *RPCError `json:"error"` +// APIResponse is the standard envelope for /api/rpc. +type APIResponse struct { + Success bool `json:"success"` + StatusCode int `json:"status_code"` + Timestamp time.Time `json:"timestamp"` + Message string `json:"message"` + Data interface{} `json:"data"` + Error *APIError `json:"error"` } -// Success builds a successful RPCResponse. -func Success(result interface{}) RPCResponse { - return RPCResponse{OK: true, Result: result, Error: nil} +// RPCResponse is an alias kept for transitional references in tests. +type RPCResponse = APIResponse + +// RPCError is an alias kept for transitional references in tests. +type RPCError = APIError + +func apiNow() time.Time { + return time.Now() +} + +// Success builds a successful APIResponse. +func Success(result interface{}) APIResponse { + return SuccessWithMessage(result, "") +} + +// SuccessWithMessage builds a successful APIResponse with a human message. +func SuccessWithMessage(result interface{}, message string) APIResponse { + if message == "" { + message = "OK" + } + return APIResponse{ + Success: true, + StatusCode: http.StatusOK, + Timestamp: apiNow(), + Message: message, + Data: result, + Error: nil, + } +} + +// Fail builds a failed APIResponse for model RPC (machine code in context). +func Fail(code, message string, details map[string]interface{}) APIResponse { + status := statusForCode(code) + ctx := details + if ctx == nil { + ctx = map[string]interface{}{} + } + if _, ok := ctx["code"]; !ok && code != "" { + ctx["code"] = code + } + return APIResponse{ + Success: false, + StatusCode: status, + Timestamp: apiNow(), + Message: message, + Data: nil, + Error: &APIError{ + Code: code, + Name: rpcErrorName(code), + Message: message, + Context: ctx, + }, + } +} + +// FailWithStatus builds Fail with an explicit HTTP status on the envelope. +func FailWithStatus(status int, code, message string, details map[string]interface{}) APIResponse { + resp := Fail(code, message, details) + resp.StatusCode = status + return resp +} + +// AuthFail builds an authentication-style error envelope. +func AuthFail(status int, code, typ, message, debug string) APIResponse { + return APIResponse{ + Success: false, + StatusCode: status, + Timestamp: apiNow(), + Message: message, + Data: nil, + Error: &APIError{ + Code: code, + Type: typ, + Context: map[string]interface{}{}, + Debug: debug, + }, + } } -// Fail builds a failed RPCResponse. -func Fail(code, message string, details map[string]interface{}) RPCResponse { - return RPCResponse{ - OK: false, - Result: nil, - Error: &RPCError{Code: code, Message: message, Details: details}, +func rpcErrorName(code string) string { + if code == "" { + return "sumeru.api.Error" } + return fmt.Sprintf("sumeru.api.%s", code) } -// WriteResponse writes an RPCResponse with the given HTTP status. -func WriteResponse(w http.ResponseWriter, status int, resp RPCResponse) { +// WriteResponse writes an APIResponse with the given HTTP status. +func WriteResponse(w http.ResponseWriter, status int, resp APIResponse) { + if resp.StatusCode == 0 { + resp.StatusCode = status + } + if resp.Timestamp.IsZero() { + resp.Timestamp = apiNow() + } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(resp) } -// ResponseFromError maps an error to an RPCResponse and HTTP status. -func ResponseFromError(err error) (RPCResponse, int) { +// ResponseFromError maps an error to an APIResponse and HTTP status. +func ResponseFromError(err error) (APIResponse, int) { if err == nil { return Success(nil), http.StatusOK } @@ -53,8 +140,8 @@ func ResponseFromError(err error) (RPCResponse, int) { return Fail(code, err.Error(), details), statusForCode(code) } -// Dispatch executes an RPC call and returns the standard envelope and HTTP status. -func Dispatch(ctx context.Context, body []byte) (RPCResponse, int) { +// Dispatch executes a legacy flat-body RPC call. +func Dispatch(ctx context.Context, body []byte) (APIResponse, int) { result, err := dispatchRPC(ctx, body) if err != nil { return ResponseFromError(err) @@ -62,6 +149,54 @@ func Dispatch(ctx context.Context, body []byte) (RPCResponse, int) { return Success(result), http.StatusOK } +// DispatchPath executes POST /api/rpc/{model}/{method} with a method-shaped body. +func DispatchPath(ctx context.Context, model, method string, body []byte) (APIResponse, int) { + built, err := BuildRPCRequestBody(method, body) + if err != nil { + if ce, ok := err.(*codedError); ok { + return Fail(ce.code, ce.msg, ce.details), statusForCode(ce.code) + } + return Fail(CodeInvalidJSON, err.Error(), nil), http.StatusBadRequest + } + in := rpcRequest{Model: model, Method: method, Args: built.args, Kwargs: built.kwargs} + payload, err := json.Marshal(in) + if err != nil { + return Fail(CodeInternalError, "could not build rpc request", nil), http.StatusInternalServerError + } + result, err := dispatchRPC(ctx, payload) + if err != nil { + resp, status := ResponseFromError(err) + resp.Message = rpcFailureMessage(model, method, err) + return resp, status + } + msg := rpcSuccessMessage(model, method) + return SuccessWithMessage(result, msg), http.StatusOK +} + +func rpcSuccessMessage(model, method string) string { + switch method { + case "search_read", "search": + return "Records retrieved successfully" + case "read": + return "Records retrieved successfully" + case "create", "create_many": + return "Record created successfully" + case "write", "write_many": + return "Record updated successfully" + case "unlink", "unlink_many": + return "Record deleted successfully" + default: + return fmt.Sprintf("%s.%s completed successfully", model, method) + } +} + +func rpcFailureMessage(model, method string, err error) string { + if err != nil && err.Error() != "" { + return err.Error() + } + return fmt.Sprintf("%s.%s failed", model, method) +} + func statusForCode(code string) int { switch code { case CodeUnauthorized: @@ -72,6 +207,8 @@ func statusForCode(code string) int { return http.StatusUnsupportedMediaType case CodePayloadTooLarge: return http.StatusRequestEntityTooLarge + case CodeRateLimited: + return http.StatusTooManyRequests case CodeMethodNotAllowed, CodeAccessDenied: return http.StatusForbidden case CodeModelNotFound, CodeNotFound: diff --git a/core/server/api/errors.go b/core/server/api/errors.go index c29bd422..3e9d958e 100644 --- a/core/server/api/errors.go +++ b/core/server/api/errors.go @@ -14,11 +14,12 @@ const ( CodeModelNotFound = "MODEL_NOT_FOUND" CodeNotFound = "NOT_FOUND" // errcode.NotFound CodeMethodNotAllowed = "METHOD_NOT_ALLOWED" - CodeUnauthorized = "UNAUTHORIZED" // errcode.Unauthorized + CodeUnauthorized = "UNAUTHORIZED" // errcode.Unauthorized CodeAccessDenied = "ACCESS_DENIED" // errcode.AccessDenied CodePayloadTooLarge = "PAYLOAD_TOO_LARGE" CodeUnsupportedMediaType = "UNSUPPORTED_MEDIA_TYPE" CodeInternalError = "INTERNAL_ERROR" // errcode.InternalError + CodeRateLimited = "RATE_LIMITED" ) type codedError struct { diff --git a/core/server/api/method_bulk.go b/core/server/api/method_bulk.go index 6aba12fb..62a101c3 100644 --- a/core/server/api/method_bulk.go +++ b/core/server/api/method_bulk.go @@ -67,7 +67,7 @@ func rpcWriteMany(ctx context.Context, model string, args json.RawMessage) (inte return nil, newRPCError(CodeInvalidArgs, fmt.Sprintf("args[1] values: %v", err), map[string]interface{}{"method": "write_many"}) } domain := make([][]interface{}, 0, len(ids)) - if len(ids) == 0 { + if 0 == len(ids) { return true, nil } idList := make([]interface{}, len(ids)) @@ -97,7 +97,7 @@ func rpcUnlinkMany(ctx context.Context, model string, args json.RawMessage) (int if err := capRPCIDs(ids); err != nil { return nil, err } - if len(ids) == 0 { + if 0 == len(ids) { return true, nil } idList := make([]interface{}, len(ids)) diff --git a/core/server/api/method_read.go b/core/server/api/method_read.go index 704d8f95..79554e84 100644 --- a/core/server/api/method_read.go +++ b/core/server/api/method_read.go @@ -35,7 +35,7 @@ func rpcRead(ctx context.Context, model string, args json.RawMessage) (interface return nil, err } } - if len(ids) == 0 { + if 0 == len(ids) { return []map[string]interface{}{}, nil } idValues := make([]interface{}, len(ids)) @@ -56,17 +56,19 @@ func rpcRead(ctx context.Context, model string, args json.RawMessage) (interface } var missing []int out := make([]map[string]interface{}, 0, len(ids)) + uid := orm.SecurityUID(ctx) for _, id := range ids { rec, ok := byID[id] if !ok { missing = append(missing, id) continue } + row := rec if len(fields) > 0 { - out = append(out, projectFields([]map[string]interface{}{rec}, fields)[0]) - } else { - out = append(out, rec) + row = projectFields([]map[string]interface{}{rec}, fields)[0] } + orm.RedactRecordForRead(ctx, uid, model, row) + out = append(out, row) } if len(missing) > 0 { return nil, newRPCError(CodeNotFound, "record(s) not found", map[string]interface{}{"missing_ids": missing}) diff --git a/core/server/api/method_read_group.go b/core/server/api/method_read_group.go index da0aa3c2..1474b121 100644 --- a/core/server/api/method_read_group.go +++ b/core/server/api/method_read_group.go @@ -41,7 +41,7 @@ func rpcReadGroup(ctx context.Context, model string, args json.RawMessage, kwarg for _, f := range spec.Fields { rg.Fields = append(rg.Fields, orm.ReadGroupField{Name: f.Name, Field: f.Field, Measure: f.Measure}) } - if len(rg.Fields) == 0 && len(rg.GroupBy) > 0 { + if 0 == len(rg.Fields) && len(rg.GroupBy) > 0 { rg.Fields = []orm.ReadGroupField{{Name: "__count", Field: "id", Measure: "count"}} } return orm.ReadGroup(ctx, model, domain, rg) diff --git a/core/server/api/method_unlink.go b/core/server/api/method_unlink.go index 027d9a5f..692b571d 100644 --- a/core/server/api/method_unlink.go +++ b/core/server/api/method_unlink.go @@ -20,6 +20,9 @@ func rpcUnlink(ctx context.Context, model string, args json.RawMessage) (interfa if err := json.Unmarshal(arr[0], &ids); err != nil { return nil, newRPCError(CodeInvalidArgs, fmt.Sprintf("args[0] ids: %v", err), map[string]interface{}{"method": "unlink"}) } + if err := capRPCIDs(ids); err != nil { + return nil, err + } for _, id := range ids { if err := orm.Unlink(ctx, model, id); err != nil { return nil, err diff --git a/core/server/api/method_write.go b/core/server/api/method_write.go index b67777eb..4c4a0f46 100644 --- a/core/server/api/method_write.go +++ b/core/server/api/method_write.go @@ -20,6 +20,9 @@ func rpcWrite(ctx context.Context, model string, args json.RawMessage) (interfac if err := json.Unmarshal(arr[0], &ids); err != nil { return nil, newRPCError(CodeInvalidArgs, fmt.Sprintf("args[0] ids: %v", err), map[string]interface{}{"method": "write"}) } + if err := capRPCIDs(ids); err != nil { + return nil, err + } var vals map[string]interface{} if err := json.Unmarshal(arr[1], &vals); err != nil { return nil, newRPCError(CodeInvalidArgs, fmt.Sprintf("args[1] values: %v", err), map[string]interface{}{"method": "write"}) diff --git a/core/server/api/parse.go b/core/server/api/parse.go index 7b28fc19..5eb0484e 100644 --- a/core/server/api/parse.go +++ b/core/server/api/parse.go @@ -8,14 +8,14 @@ import ( ) func parseDomainArg(raw json.RawMessage) ([][]interface{}, error) { - if len(raw) == 0 || string(raw) == "null" { + if 0 == len(raw) || string(raw) == "null" { return nil, nil } return orm.ParseDomainJSON(string(raw)) } func normArgs(raw json.RawMessage) json.RawMessage { - if len(raw) == 0 || string(raw) == "null" { + if 0 == len(raw) || string(raw) == "null" { return []byte("[]") } return raw @@ -23,7 +23,7 @@ func normArgs(raw json.RawMessage) json.RawMessage { func parseLimitOffset(kwargs json.RawMessage) (limit int, offset int) { limit, offset = 500, 0 - if len(kwargs) == 0 || string(kwargs) == "null" { + if 0 == len(kwargs) || string(kwargs) == "null" { return limit, offset } var m map[string]interface{} @@ -40,7 +40,7 @@ func parseLimitOffset(kwargs json.RawMessage) (limit int, offset int) { offset = int(f) } } - if limit <= 0 { + if 0 >= limit { limit = 500 } return limit, offset diff --git a/core/server/api/project_fields.go b/core/server/api/project_fields.go index b8edb588..97a204b8 100644 --- a/core/server/api/project_fields.go +++ b/core/server/api/project_fields.go @@ -3,7 +3,7 @@ package api // projectFields returns rows limited to the requested field names. // An empty fields list returns rows unchanged. func projectFields(rows []map[string]interface{}, fields []string) []map[string]interface{} { - if len(fields) == 0 { + if 0 == len(fields) { return rows } set := map[string]struct{}{} diff --git a/core/server/api/rpc_body.go b/core/server/api/rpc_body.go new file mode 100644 index 00000000..c86b17e6 --- /dev/null +++ b/core/server/api/rpc_body.go @@ -0,0 +1,272 @@ +package api + +import ( + "encoding/json" + "fmt" +) + +type builtRPCBody struct { + args json.RawMessage + kwargs json.RawMessage +} + +// BuildRPCRequestBody maps a flat JSON body to legacy args/kwargs for dispatchRPC. +func BuildRPCRequestBody(method string, raw []byte) (builtRPCBody, error) { + if len(raw) == 0 { + return builtRPCBody{}, newRPCError(CodeInvalidJSON, "empty body", nil) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return builtRPCBody{}, newRPCError(CodeInvalidJSON, fmt.Sprintf("invalid json: %v", err), nil) + } + kwargs := extractContextKwargs(fields) + switch method { + case "search": + return buildSearch(fields, kwargs) + case "search_read": + return buildSearchRead(fields, kwargs) + case "read": + return buildRead(fields, kwargs) + case "create": + return buildCreate(fields, kwargs) + case "write": + return buildWrite(fields, kwargs) + case "unlink": + return buildUnlink(fields, kwargs) + case "create_many": + return buildCreateMany(fields, kwargs) + case "write_many": + return buildWriteMany(fields, kwargs) + case "unlink_many": + return buildUnlinkMany(fields, kwargs) + case "read_group": + return buildReadGroup(fields, kwargs) + case "onchange": + return buildOnchange(fields, kwargs) + case "call": + return buildCall(fields, kwargs) + default: + return builtRPCBody{}, newRPCError(CodeMethodNotAllowed, fmt.Sprintf("unsupported method %q", method), map[string]interface{}{"method": method}) + } +} + +func extractContextKwargs(fields map[string]json.RawMessage) json.RawMessage { + raw, ok := fields["context"] + if !ok || len(raw) == 0 || string(raw) == "null" { + return nil + } + kw := map[string]json.RawMessage{"context": raw} + for _, key := range []string{"limit", "offset", "order"} { + if v, ok := fields[key]; ok { + kw[key] = v + } + } + out, err := json.Marshal(kw) + if err != nil { + return nil + } + return out +} + +func mergeKwargs(base json.RawMessage, extra map[string]json.RawMessage) (json.RawMessage, error) { + m := map[string]interface{}{} + if len(base) > 0 && string(base) != "null" { + if err := json.Unmarshal(base, &m); err != nil { + return nil, err + } + } + for k, v := range extra { + var decoded interface{} + if err := json.Unmarshal(v, &decoded); err != nil { + return nil, err + } + m[k] = decoded + } + if len(m) == 0 { + return nil, nil + } + return json.Marshal(m) +} + +func requireField(fields map[string]json.RawMessage, name string) (json.RawMessage, error) { + raw, ok := fields[name] + if !ok || len(raw) == 0 || string(raw) == "null" { + return nil, newRPCError(CodeValidationError, fmt.Sprintf("%s is required", name), map[string]interface{}{"field": name}) + } + return raw, nil +} + +func buildSearch(fields map[string]json.RawMessage, kwargs json.RawMessage) (builtRPCBody, error) { + domain, err := requireField(fields, "domain") + if err != nil { + return builtRPCBody{}, err + } + args, err := json.Marshal([]json.RawMessage{domain}) + if err != nil { + return builtRPCBody{}, err + } + return builtRPCBody{args: args, kwargs: kwargs}, nil +} + +func buildSearchRead(fields map[string]json.RawMessage, kwargs json.RawMessage) (builtRPCBody, error) { + domain, err := requireField(fields, "domain") + if err != nil { + return builtRPCBody{}, err + } + fieldsRaw, err := requireField(fields, "fields") + if err != nil { + return builtRPCBody{}, err + } + extra := map[string]json.RawMessage{} + for _, key := range []string{"limit", "offset", "order"} { + if v, ok := fields[key]; ok { + extra[key] = v + } + } + kw, err := mergeKwargs(kwargs, extra) + if err != nil { + return builtRPCBody{}, newRPCError(CodeInvalidArgs, err.Error(), nil) + } + args, err := json.Marshal([]json.RawMessage{domain, fieldsRaw}) + if err != nil { + return builtRPCBody{}, err + } + return builtRPCBody{args: args, kwargs: kw}, nil +} + +func buildRead(fields map[string]json.RawMessage, kwargs json.RawMessage) (builtRPCBody, error) { + ids, err := requireField(fields, "ids") + if err != nil { + return builtRPCBody{}, err + } + parts := []json.RawMessage{ids} + if f, ok := fields["fields"]; ok && len(f) > 0 && string(f) != "null" { + parts = append(parts, f) + } + args, err := json.Marshal(parts) + if err != nil { + return builtRPCBody{}, err + } + return builtRPCBody{args: args, kwargs: kwargs}, nil +} + +func buildCreate(fields map[string]json.RawMessage, kwargs json.RawMessage) (builtRPCBody, error) { + values, err := requireField(fields, "values") + if err != nil { + return builtRPCBody{}, err + } + args, err := json.Marshal([]json.RawMessage{values}) + if err != nil { + return builtRPCBody{}, err + } + return builtRPCBody{args: args, kwargs: kwargs}, nil +} + +func buildWrite(fields map[string]json.RawMessage, kwargs json.RawMessage) (builtRPCBody, error) { + ids, err := requireField(fields, "ids") + if err != nil { + return builtRPCBody{}, err + } + values, err := requireField(fields, "values") + if err != nil { + return builtRPCBody{}, err + } + args, err := json.Marshal([]json.RawMessage{ids, values}) + if err != nil { + return builtRPCBody{}, err + } + return builtRPCBody{args: args, kwargs: kwargs}, nil +} + +func buildUnlink(fields map[string]json.RawMessage, kwargs json.RawMessage) (builtRPCBody, error) { + ids, err := requireField(fields, "ids") + if err != nil { + return builtRPCBody{}, err + } + args, err := json.Marshal([]json.RawMessage{ids}) + if err != nil { + return builtRPCBody{}, err + } + return builtRPCBody{args: args, kwargs: kwargs}, nil +} + +func buildCreateMany(fields map[string]json.RawMessage, kwargs json.RawMessage) (builtRPCBody, error) { + values, err := requireField(fields, "values") + if err != nil { + // allow list at top level as values array + if v, ok := fields["records"]; ok { + values = v + } else { + return builtRPCBody{}, err + } + } + args, err := json.Marshal([]json.RawMessage{values}) + if err != nil { + return builtRPCBody{}, err + } + return builtRPCBody{args: args, kwargs: kwargs}, nil +} + +func buildWriteMany(fields map[string]json.RawMessage, kwargs json.RawMessage) (builtRPCBody, error) { + return buildWrite(fields, kwargs) +} + +func buildUnlinkMany(fields map[string]json.RawMessage, kwargs json.RawMessage) (builtRPCBody, error) { + return buildUnlink(fields, kwargs) +} + +func buildReadGroup(fields map[string]json.RawMessage, kwargs json.RawMessage) (builtRPCBody, error) { + spec, ok := fields["spec"] + if !ok || len(spec) == 0 { + specBytes, err := json.Marshal(map[string]json.RawMessage{ + "domain": fields["domain"], + "groupby": fields["groupby"], + "fields": fields["fields"], + }) + if err != nil { + return builtRPCBody{}, err + } + spec = specBytes + } + args, err := json.Marshal([]json.RawMessage{spec}) + if err != nil { + return builtRPCBody{}, err + } + return builtRPCBody{args: args, kwargs: kwargs}, nil +} + +func buildOnchange(fields map[string]json.RawMessage, kwargs json.RawMessage) (builtRPCBody, error) { + values, err := requireField(fields, "values") + if err != nil { + return builtRPCBody{}, err + } + field, err := requireField(fields, "field") + if err != nil { + return builtRPCBody{}, err + } + args, err := json.Marshal([]json.RawMessage{values, field}) + if err != nil { + return builtRPCBody{}, err + } + return builtRPCBody{args: args, kwargs: kwargs}, nil +} + +func buildCall(fields map[string]json.RawMessage, kwargs json.RawMessage) (builtRPCBody, error) { + idRaw, err := requireField(fields, "id") + if err != nil { + return builtRPCBody{}, err + } + method, err := requireField(fields, "method") + if err != nil { + return builtRPCBody{}, err + } + parts := []json.RawMessage{idRaw, method} + if v, ok := fields["vals"]; ok && len(v) > 0 && string(v) != "null" { + parts = append(parts, v) + } + args, err := json.Marshal(parts) + if err != nil { + return builtRPCBody{}, err + } + return builtRPCBody{args: args, kwargs: kwargs}, nil +} diff --git a/core/server/auth/oidc_verify.go b/core/server/auth/oidc_verify.go index 675246d3..742797de 100644 --- a/core/server/auth/oidc_verify.go +++ b/core/server/auth/oidc_verify.go @@ -253,7 +253,7 @@ func fetchJWKS(ctx context.Context, jwksURL string) (map[string]crypto.PublicKey out[kid] = pub } } - if len(out) == 0 { + if 0 == len(out) { return nil, errors.New("no usable jwks keys") } return out, nil @@ -272,7 +272,7 @@ func rsaPublicFromJWK(nB64, eB64 string) (*rsa.PublicKey, error) { for _, b := range eBytes { eInt = eInt<<8 + int(b) } - if eInt == 0 { + if 0 == eInt { eInt = 65537 } return &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: eInt}, nil diff --git a/core/server/auth/password.go b/core/server/auth/password.go new file mode 100644 index 00000000..1d2b190b --- /dev/null +++ b/core/server/auth/password.go @@ -0,0 +1,27 @@ +package auth + +import ( + "strings" + + "golang.org/x/crypto/bcrypt" +) + +var dummyPasswordHash = mustDummyBcryptHash() + +func mustDummyBcryptHash() string { + h, err := bcrypt.GenerateFromPassword([]byte("dummy"), bcrypt.DefaultCost) + if err != nil { + panic(err) + } + return string(h) +} + +// ComparePasswordHash returns true when plain matches stored bcrypt hash. +func ComparePasswordHash(storedHash, plain string) bool { + storedHash = strings.TrimSpace(storedHash) + if storedHash == "" { + _ = bcrypt.CompareHashAndPassword([]byte(dummyPasswordHash), []byte(plain)) + return false + } + return bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(plain)) == nil +} diff --git a/core/server/auth/totp_qr_png.go b/core/server/auth/totp_qr_png.go index 75908c17..6c23cacd 100644 --- a/core/server/auth/totp_qr_png.go +++ b/core/server/auth/totp_qr_png.go @@ -6,7 +6,7 @@ const totpQRQuietBorder = 4 // TOTPQRPng returns a PNG image for an otpauth URI. func TOTPQRPng(otpauthURI string, size int) ([]byte, error) { - if size <= 0 { + if 0 >= size { size = 200 } qr, err := go_qr.EncodeText(otpauthURI, go_qr.Medium) diff --git a/core/server/cliboot/cliboot.go b/core/server/cliboot/cliboot.go index cc77fa78..d75ba18a 100644 --- a/core/server/cliboot/cliboot.go +++ b/core/server/cliboot/cliboot.go @@ -110,7 +110,7 @@ func InitOptionalDB(configPath string, requireDB bool) (context.Context, error) // ContextWithUID returns ctx with security uid set for ORM calls. func ContextWithUID(ctx context.Context, uid int) context.Context { - if uid <= 0 { + if 0 >= uid { uid = 1 } return orm.ContextWithUID(ctx, uid) diff --git a/core/server/config/config.go b/core/server/config/config.go index 0037bffc..43efd803 100644 --- a/core/server/config/config.go +++ b/core/server/config/config.go @@ -46,6 +46,7 @@ type Config struct { DbReadReplicaDSN string // optional libpq DSN for read replica (search/read_group RPC) RateLimitRPM int // rate_limit_rpm per client IP on /api/rpc and login; 0 = disabled TrustedProxies string // trusted_proxies: comma-separated CIDRs/IPs allowed to set X-Forwarded-For; empty = never trust XFF + WebBaseURL string // web_base_url: canonical public origin (scheme + host); required for OAuth behind untrusted Host CSRFSecret string // csrf_secret: shared HMAC key for multi-instance; empty = ephemeral per process ForceSecureCookies bool // force_secure_cookies: set Secure on session/flash cookies even when dev_mode=true SessionCookieStrictSameSite bool // session_cookie_strict_samesite: use SameSite=Strict on session cookie (default Lax) @@ -58,6 +59,8 @@ type Config struct { SMTPUser string SMTPPassword string SMTPFrom string + TLSCertFile string // tls_cert_file: enable HTTPS + HTTP/2 via ALPN when set with tls_key_file + TLSKeyFile string // tls_key_file } var AppConfig Config @@ -187,6 +190,8 @@ func LoadConfig(path string) error { } case keyTrustedProxies: AppConfig.TrustedProxies = val + case keyWebBaseURL: + AppConfig.WebBaseURL = val case keyCSRFSecret: AppConfig.CSRFSecret = val case keyForceSecureCookies: @@ -217,6 +222,10 @@ func LoadConfig(path string) error { AppConfig.SMTPPassword = val case keySMTPFrom: AppConfig.SMTPFrom = val + case keyTLSCertFile: + AppConfig.TLSCertFile = val + case keyTLSKeyFile: + AppConfig.TLSKeyFile = val } } diff --git a/core/server/config/const.go b/core/server/config/const.go index b3325524..4a4674bd 100644 --- a/core/server/config/const.go +++ b/core/server/config/const.go @@ -44,6 +44,7 @@ const ( keyDbReadReplicaDSN = "db_read_replica_dsn" keyRateLimitRPM = "rate_limit_rpm" keyTrustedProxies = "trusted_proxies" + keyWebBaseURL = "web_base_url" keyCSRFSecret = "csrf_secret" keyForceSecureCookies = "force_secure_cookies" keySessionCookieStrictSameSite = "session_cookie_strict_samesite" @@ -56,6 +57,8 @@ const ( keySMTPUser = "smtp_user" keySMTPPassword = "smtp_password" keySMTPFrom = "smtp_from" + keyTLSCertFile = "tls_cert_file" + keyTLSKeyFile = "tls_key_file" ) const ( diff --git a/core/server/run.go b/core/server/run.go index 52a45bce..a48649c4 100644 --- a/core/server/run.go +++ b/core/server/run.go @@ -13,11 +13,11 @@ import ( "time" "sumeru/core/applog" - _ "sumeru/core/ormmodels" "sumeru/core/metrics" "sumeru/core/modelreg" "sumeru/core/module" "sumeru/core/orm" + _ "sumeru/core/ormmodels" "sumeru/core/runtime" "sumeru/core/scheduler" "sumeru/core/sdk" @@ -173,6 +173,9 @@ func Run() { if err := RunModuleCLI(*installMods, *updateMods); err != nil { applog.Fatal(ctx, "Module CLI failed", "err", err) } + if err := module.SyncInstalledAddonPOFiles(ctx); err != nil { + applog.Fatal(ctx, "Translation catalog sync failed", "err", err) + } if err := orm.EnsureBootstrapSecurity(); err != nil { applog.Fatal(ctx, "Security bootstrap failed", "err", err) @@ -228,6 +231,12 @@ func newHTTPServer(addr string, handler http.Handler) *http.Server { func serveUntilSignal(ctx context.Context, srv *http.Server) error { errCh := make(chan error, 1) go func() { + cert := strings.TrimSpace(config.AppConfig.TLSCertFile) + key := strings.TrimSpace(config.AppConfig.TLSKeyFile) + if cert != "" && key != "" { + errCh <- srv.ListenAndServeTLS(cert, key) + return + } errCh <- srv.ListenAndServe() }() select { diff --git a/core/server/web/action_context.go b/core/server/web/action_context.go index 4c8c59b8..6b2bb840 100644 --- a/core/server/web/action_context.go +++ b/core/server/web/action_context.go @@ -38,7 +38,7 @@ func actionSearchViewIDFromContext(actionData map[string]interface{}) string { // actionDefaultFieldValues extracts default_* keys from action context for new-record forms. func actionDefaultFieldValues(actionData map[string]interface{}) map[string]interface{} { ctx := parseActionContext(actionData) - if len(ctx) == 0 { + if 0 == len(ctx) { return nil } out := make(map[string]interface{}) @@ -52,7 +52,7 @@ func actionDefaultFieldValues(actionData map[string]interface{}) map[string]inte } out[fieldName] = value } - if len(out) == 0 { + if 0 == len(out) { return nil } return out diff --git a/core/server/web/action_domain.go b/core/server/web/action_domain.go index 0790f42f..b0dce8aa 100644 --- a/core/server/web/action_domain.go +++ b/core/server/web/action_domain.go @@ -13,9 +13,13 @@ func actionListDomain(ctx context.Context, actionData map[string]interface{}) [] return nil } dom, err := orm.ParseDomainJSON(raw) - if err != nil || len(dom) == 0 { + if err != nil { return nil } + if 0 == len(dom) { + return nil + } + dom = orm.ResolveDomainXMLRefs(ctx, dom) return orm.SubstituteDomainUID(dom, orm.SecurityUID(ctx)) } diff --git a/core/server/web/apikey_handlers.go b/core/server/web/apikey_handlers.go index f5766cbb..f82e68c0 100644 --- a/core/server/web/apikey_handlers.go +++ b/core/server/web/apikey_handlers.go @@ -54,7 +54,7 @@ func ActionCreateAPIKey(w http.ResponseWriter, r *http.Request) { func apiKeyTargetUserID(r *http.Request) int { sessionUID := SessionUserID(r) userID, _ := strconv.Atoi(strings.TrimSpace(r.PostFormValue("user_id"))) - if userID <= 0 || userID == sessionUID { + if 0 >= userID || userID == sessionUID { return sessionUID } if orm.UserHasGroupXML(r.Context(), sessionUID, groupSystemXML) { diff --git a/core/server/web/apps_catalog.go b/core/server/web/apps_catalog.go index ca707f34..5f0d85e2 100644 --- a/core/server/web/apps_catalog.go +++ b/core/server/web/apps_catalog.go @@ -106,7 +106,7 @@ func appsModuleMatchesCategory(moduleEntry appsModule, categoryFilter string) bo func groupAppsModules(modules []appsModule, groupBy string) []appsModuleGroup { if groupBy != appsGroupByCategory { - if len(modules) == 0 { + if 0 == len(modules) { return nil } return []appsModuleGroup{{Modules: modules}} diff --git a/core/server/web/apps_detail.go b/core/server/web/apps_detail.go index 096793ad..6b8c4270 100644 --- a/core/server/web/apps_detail.go +++ b/core/server/web/apps_detail.go @@ -13,23 +13,23 @@ import ( // appsModuleDetailVM is the read-only app detail view for one sys.module row on the Apps screen. type appsModuleDetailVM struct { - Layout string - Editing bool - Name, DisplayName, Author, Version string + Layout string + Editing bool + Name, DisplayName, Author, Version string Description, Summary, CategoryLabel string - State, StatusLabel string - HasLongDescription bool - Active bool - IsApplication, IsCore bool - ID int - IconURL, IconLetter string - IconHue int - OpenAppURL string - Depends []appsModuleDep - CanInstall, CanUninstall bool - CanDeactivate, CanActivate bool - BackAppsQuery string - EditURL, CancelURL string + State, StatusLabel string + HasLongDescription bool + Active bool + IsApplication, IsCore bool + ID int + IconURL, IconLetter string + IconHue int + OpenAppURL string + Depends []appsModuleDep + CanInstall, CanUninstall bool + CanDeactivate, CanActivate bool + BackAppsQuery string + EditURL, CancelURL string } const appsListBreadcrumb = "Applications" @@ -76,22 +76,22 @@ func loadAppsModuleDetail( HasLongDescription: moduleHasLongDescription(summary, parsed.Description), State: parsed.State, StatusLabel: moduleStatusLabel(parsed), - Active: parsed.Active, - IsApplication: parsed.Application, - IsCore: listEntry.IsCore, - ID: recordID, - IconURL: listEntry.IconURL, - IconLetter: listEntry.IconLetter, - IconHue: listEntry.IconHue, - OpenAppURL: moduleOpenAppURL(ctx, parsed), - Depends: moduleDepends(parsed.Name, listedModules, browse), - CanInstall: listEntry.CanInstall, - CanUninstall: listEntry.CanUninstall, - CanDeactivate: listEntry.CanDeactivate, - CanActivate: listEntry.CanActivate, - BackAppsQuery: browseQuery, - EditURL: appsDetailPageURL(moduleBrowse, true), - CancelURL: appsDetailPageURL(moduleBrowse, false), + Active: parsed.Active, + IsApplication: parsed.Application, + IsCore: listEntry.IsCore, + ID: recordID, + IconURL: listEntry.IconURL, + IconLetter: listEntry.IconLetter, + IconHue: listEntry.IconHue, + OpenAppURL: moduleOpenAppURL(ctx, parsed), + Depends: moduleDepends(parsed.Name, listedModules, browse), + CanInstall: listEntry.CanInstall, + CanUninstall: listEntry.CanUninstall, + CanDeactivate: listEntry.CanDeactivate, + CanActivate: listEntry.CanActivate, + BackAppsQuery: browseQuery, + EditURL: appsDetailPageURL(moduleBrowse, true), + CancelURL: appsDetailPageURL(moduleBrowse, false), } breadcrumb = "Apps · " + detail.DisplayName return detail, breadcrumb, true @@ -114,7 +114,7 @@ func moduleOpenAppURL(ctx context.Context, parsed moduleRow) string { return "" } menuID := render.RootMenuIDForModule(ctx, parsed.Name) - if menuID <= 0 { + if 0 >= menuID { return "" } return fmt.Sprintf("/web?menu_id=%d", menuID) @@ -126,7 +126,7 @@ func moduleDepends(moduleName string, listedModules []appsModule, browse appsBro return nil } deps := addon.Manifest.Depends - if len(deps) == 0 { + if 0 == len(deps) { return nil } out := make([]appsModuleDep, 0, len(deps)) diff --git a/core/server/web/apps_list.go b/core/server/web/apps_list.go index 4a934058..63100a1d 100644 --- a/core/server/web/apps_list.go +++ b/core/server/web/apps_list.go @@ -63,7 +63,7 @@ func AppsHandler(w http.ResponseWriter, r *http.Request) { if !requireLogin(w, r) { return } - if !requireSystemAdmin(w, r, true) { + if !requireModuleManagerOrSystem(w, r, true) { return } @@ -165,7 +165,7 @@ func buildAppsModuleList(moduleRows []map[string]interface{}) []appsModule { } func enrichAppsModules(ctx context.Context, modules []appsModule, browse appsBrowseState) []appsModule { - if len(modules) == 0 { + if 0 == len(modules) { return modules } out := make([]appsModule, len(modules)) diff --git a/core/server/web/apps_module_actions.go b/core/server/web/apps_module_actions.go index 9d098582..7ed01d11 100644 --- a/core/server/web/apps_module_actions.go +++ b/core/server/web/apps_module_actions.go @@ -117,7 +117,7 @@ func runModuleLifecycleAction(ctx context.Context, action, moduleName string) (f func saveModuleFromForm(r *http.Request, moduleName string) error { recordID, err := strconv.Atoi(strings.TrimSpace(r.FormValue(moduleRowIDField))) - if err != nil || recordID <= 0 { + if err != nil || 0 >= recordID { return errors.New(moduleMsgInvalidModuleRow) } diff --git a/core/server/web/auth.go b/core/server/web/auth.go index c67377d2..f9f30785 100644 --- a/core/server/web/auth.go +++ b/core/server/web/auth.go @@ -3,9 +3,7 @@ package web import ( "bufio" "context" - "crypto/rand" "database/sql" - "encoding/hex" "errors" "fmt" "net" @@ -20,7 +18,6 @@ import ( ) const sessionCookieName = "sumeru_session" -const sessionDuration = 24 * time.Hour const sessionSlidingTTL = 8 * time.Hour var testSessionUserIDOverride int @@ -208,7 +205,7 @@ func resolveSession(r *http.Request) sessionState { return sessionState{clearCookie: true} } } - if !active || userID <= 0 { + if !active || 0 >= userID { deleteSession(r.Context(), sid) applog.WarnCode(r.Context(), errcode.AccessDenied, "session revoked for inactive user", applog.Event{ Component: "web", @@ -251,23 +248,16 @@ func deleteSession(ctx context.Context, sid string) { } } -func CreateSession(w http.ResponseWriter, userID int) error { +func CreateSession(w http.ResponseWriter, ctx context.Context, userID int) error { if orm.DB == nil { return fmt.Errorf("no database") } - sessionBytes := make([]byte, 24) - if _, err := rand.Read(sessionBytes); err != nil { - return err - } - sessionID := hex.EncodeToString(sessionBytes) - expiresAt := time.Now().UTC().Add(sessionDuration) - sessionTbl := orm.MustQuotedTableName("sys.session") - var err error - if sessionBrowserPresenceEnabled() { - _, err = orm.DB.Exec(`INSERT INTO `+sessionTbl+` (sid, user_id, expires_at, browser_seen_at) VALUES ($1, $2, $3, NOW())`, sessionID, userID, expiresAt) - } else { - _, err = orm.DB.Exec(`INSERT INTO `+sessionTbl+` (sid, user_id, expires_at) VALUES ($1, $2, $3)`, sessionID, userID, expiresAt) + if ctx == nil { + ctx = context.Background() } + sessionID, err := orm.CreateUserSession(ctx, userID, orm.SessionCreateOpts{ + StampBrowserSeen: sessionBrowserPresenceEnabled(), + }) if err != nil { return err } @@ -322,7 +312,7 @@ func AuthenticatedUserID(r *http.Request) int { func requireLogin(w http.ResponseWriter, r *http.Request) bool { uid := SessionUserID(r) - if uid <= 0 { + if 0 >= uid { redirectToLogin(w, r, r.URL.RequestURI()) return false } @@ -351,7 +341,7 @@ func enrichRequestContext(r *http.Request, requestID string, session sessionStat ctx := applog.ContextWithRequestID(r.Context(), requestID) ctx = withSessionState(ctx, session) userID := session.userID - if userID <= 0 { + if 0 >= userID { userID = APIKeyUserID(r) } ctx = orm.ContextWithUID(ctx, userID) @@ -367,8 +357,12 @@ func sessionActiveCompanyID(ctx context.Context, userID int) int64 { return companyID } ids, err := orm.UserCompanyIDs(ctx, userID) - if err != nil || len(ids) == 0 { + if err != nil { + return 0 + } + if 0 == len(ids) { return 0 } + return ids[0] } diff --git a/core/server/web/auth_helpers.go b/core/server/web/auth_helpers.go index 9b32b956..8974852f 100644 --- a/core/server/web/auth_helpers.go +++ b/core/server/web/auth_helpers.go @@ -33,7 +33,7 @@ func writeJSONResponse(w http.ResponseWriter, v interface{}) { // resolveRequestUID returns SecurityUID from context, falling back to the session cookie. func resolveRequestUID(r *http.Request) int { uid := orm.SecurityUID(r.Context()) - if uid <= 0 { + if 0 >= uid { uid = SessionUserID(r) } return uid @@ -53,6 +53,20 @@ func requireSystemAdmin(w http.ResponseWriter, r *http.Request, redirectOnDeny b return false } +func requireModuleManagerOrSystem(w http.ResponseWriter, r *http.Request, redirectOnDeny bool) bool { + ctx := r.Context() + uid := resolveRequestUID(r) + if orm.UserHasGroupXML(ctx, uid, groupSystemXML) || orm.UserHasGroupXML(ctx, uid, groupModuleManagerXML) { + return true + } + if redirectOnDeny { + http.Redirect(w, r, homeRoute, http.StatusFound) + } else { + http.Error(w, forbiddenMessage, http.StatusForbidden) + } + return false +} + func requireModelAccess(w http.ResponseWriter, r *http.Request, model, perm string) bool { if err := orm.CheckModelAccess(r.Context(), orm.SecurityUID(r.Context()), model, perm); err != nil { WebLogEvent(r.Context(), WebLogInput{ diff --git a/core/server/web/bulk_handlers.go b/core/server/web/bulk_handlers.go index e49ab7ff..fbb08dce 100644 --- a/core/server/web/bulk_handlers.go +++ b/core/server/web/bulk_handlers.go @@ -9,6 +9,7 @@ import ( "sumeru/core/orm" "sumeru/core/report" + "sumeru/core/server/api" ) // BulkUploadHandler POST /web/bulk/upload — stage CSV and open mapping view. @@ -31,7 +32,7 @@ func BulkUploadHandler(w http.ResponseWriter, r *http.Request) { } defer upload.Close() content, err := io.ReadAll(upload) - if err != nil || len(content) == 0 { + if err != nil || 0 == len(content) { http.Error(w, "empty file", http.StatusBadRequest) return } @@ -85,7 +86,10 @@ func BulkPreviewHandler(w http.ResponseWriter, r *http.Request) { Mapping map[string]string `json:"column_mapping"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.BatchID <= 0 { - http.Error(w, "invalid body", http.StatusBadRequest) + writeWebJSONError(w, http.StatusBadRequest, api.CodeInvalidBody, "invalid body", nil) + return + } + if _, ok := loadOwnedBulkBatch(w, r, body.BatchID); !ok { return } out, err := report.PreviewBulkImport(r.Context(), report.PreviewBulkImportInput{ @@ -93,7 +97,7 @@ func BulkPreviewHandler(w http.ResponseWriter, r *http.Request) { Mapping: body.Mapping, }) if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writeWebJSONError(w, http.StatusBadRequest, api.CodeValidationError, err.Error(), nil) return } writeJSONResponse(w, out) @@ -111,17 +115,16 @@ func BulkSaveImportTemplateHandler(w http.ResponseWriter, r *http.Request) { Shared bool `json:"shared"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.BatchID <= 0 { - http.Error(w, "invalid body", http.StatusBadRequest) + writeWebJSONError(w, http.StatusBadRequest, api.CodeInvalidBody, "invalid body", nil) return } name := strings.TrimSpace(body.Name) if name == "" { - http.Error(w, "name required", http.StatusBadRequest) + writeWebJSONError(w, http.StatusBadRequest, api.CodeValidationError, "name required", nil) return } - batch, err := orm.SearchOne(r.Context(), report.BulkModelName, map[string]interface{}{"id": body.BatchID}) - if err != nil || len(batch) == 0 { - http.NotFound(w, r) + batch, ok := loadOwnedBulkBatch(w, r, body.BatchID) + if !ok { return } targetModel := orm.AsString(batch["target_model"]) @@ -129,16 +132,17 @@ func BulkSaveImportTemplateHandler(w http.ResponseWriter, r *http.Request) { return } mapping := body.Mapping - if len(mapping) == 0 { - mapping, err = report.ParseMappingJSON(orm.AsString(batch["column_mapping"])) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + if 0 == len(mapping) { + var parseErr error + mapping, parseErr = report.ParseMappingJSON(orm.AsString(batch["column_mapping"])) + if parseErr != nil { + writeWebJSONError(w, http.StatusBadRequest, api.CodeValidationError, parseErr.Error(), nil) return } } mappingJSON, err := json.Marshal(mapping) if err != nil { - http.Error(w, "mapping encode failed", http.StatusBadRequest) + writeWebJSONError(w, http.StatusBadRequest, api.CodeInternalError, "mapping encode failed", nil) return } selected := orm.AsString(batch["selected_fields"]) @@ -146,7 +150,7 @@ func BulkSaveImportTemplateHandler(w http.ResponseWriter, r *http.Request) { uid := AuthenticatedUserID(r) templateID, err := report.SaveImportTemplate(r.Context(), name, targetModel, importMode, selected, string(mappingJSON), uid, body.Shared) if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writeWebJSONError(w, http.StatusBadRequest, api.CodeValidationError, err.Error(), nil) return } writeJSONResponse(w, map[string]interface{}{"template_id": templateID}) @@ -162,7 +166,10 @@ func BulkDryRunHandler(w http.ResponseWriter, r *http.Request) { Mapping map[string]string `json:"column_mapping"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.BatchID <= 0 { - http.Error(w, "invalid body", http.StatusBadRequest) + writeWebJSONError(w, http.StatusBadRequest, api.CodeInvalidBody, "invalid body", nil) + return + } + if _, ok := loadOwnedBulkBatch(w, r, body.BatchID); !ok { return } preview, messages, err := report.DryRunBulkImport(r.Context(), report.PreviewBulkImportInput{ @@ -170,7 +177,7 @@ func BulkDryRunHandler(w http.ResponseWriter, r *http.Request) { Mapping: body.Mapping, }) if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writeWebJSONError(w, http.StatusBadRequest, api.CodeValidationError, err.Error(), nil) return } writeJSONResponse(w, map[string]interface{}{"preview": preview, "messages": messages}) @@ -189,15 +196,15 @@ func BulkConfirmHandler(w http.ResponseWriter, r *http.Request) { return } batchID := report.ParseActionIDParam(r.FormValue("id")) - if batchID <= 0 { - http.Error(w, "id required", http.StatusBadRequest) - return - } mapping, err := report.ParseMappingJSON(r.FormValue("column_mapping")) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } + batch, ok := loadOwnedBulkBatch(w, r, batchID) + if !ok { + return + } skipInvalid := r.FormValue("skip_invalid") == "1" result, err := report.ExecuteBulkImport(r.Context(), report.ExecuteBulkImportInput{ BatchID: batchID, @@ -208,7 +215,6 @@ func BulkConfirmHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - batch, _ := orm.SearchOne(r.Context(), report.BulkModelName, map[string]interface{}{"id": batchID}) next := orm.AsString(batch["next_url"]) redirectWithWebMessage(w, r, next, report.ImportFlashMessage(result)) } @@ -223,18 +229,14 @@ func BulkCancelHandler(w http.ResponseWriter, r *http.Request) { } _ = r.ParseForm() batchID := report.ParseActionIDParam(r.FormValue("id")) - if batchID <= 0 { - http.Error(w, "id required", http.StatusBadRequest) + batch, ok := loadOwnedBulkBatch(w, r, batchID) + if !ok { return } if err := report.CancelBatch(r.Context(), batchID); err != nil { http.Error(w, "cancel failed: "+err.Error(), http.StatusInternalServerError) return } - batch, err := orm.SearchOne(r.Context(), report.BulkModelName, map[string]interface{}{"id": batchID}) - next := homeRoute - if err == nil { - next = SafeWebNext(orm.AsString(batch["next_url"]), homeRoute) - } + next := SafeWebNext(orm.AsString(batch["next_url"]), homeRoute) http.Redirect(w, r, next, http.StatusSeeOther) } diff --git a/core/server/web/bulk_status.go b/core/server/web/bulk_status.go index 0b9e7b06..4b84c8cf 100644 --- a/core/server/web/bulk_status.go +++ b/core/server/web/bulk_status.go @@ -9,25 +9,37 @@ import ( "sumeru/core/report" ) +func bulkBatchOwnedBy(batch map[string]interface{}, uid int) bool { + ownerID, _ := orm.CoerceInt64(batch["user_id"]) + return uid > 0 && int(ownerID) == uid +} + +// loadOwnedBulkBatch loads sys.bulk.import when batchID belongs to the logged-in user. +func loadOwnedBulkBatch(w http.ResponseWriter, r *http.Request, batchID int) (map[string]interface{}, bool) { + if 0 >= batchID { + http.Error(w, "id required", http.StatusBadRequest) + return nil, false + } + batch, err := orm.SearchOne(r.Context(), report.BulkModelName, map[string]interface{}{"id": batchID}) + if err != nil || 0 == len(batch) { + http.NotFound(w, r) + return nil, false + } + if !bulkBatchOwnedBy(batch, AuthenticatedUserID(r)) { + http.Error(w, "forbidden", http.StatusForbidden) + return nil, false + } + return batch, true +} + // BulkStatusHandler GET /web/bulk/status?id= — job state for queued import/export batches. func BulkStatusHandler(w http.ResponseWriter, r *http.Request) { if !requireLogin(w, r) { return } id, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("id"))) - if id <= 0 { - http.Error(w, "id required", http.StatusBadRequest) - return - } - uid := AuthenticatedUserID(r) - batch, err := orm.SearchOne(r.Context(), report.BulkModelName, map[string]interface{}{"id": id}) - if err != nil || len(batch) == 0 { - http.NotFound(w, r) - return - } - ownerID, _ := orm.CoerceInt64(batch["user_id"]) - if int(ownerID) != uid { - http.Error(w, "forbidden", http.StatusForbidden) + batch, ok := loadOwnedBulkBatch(w, r, id) + if !ok { return } state := strings.TrimSpace(orm.AsString(batch["state"])) diff --git a/core/server/web/company_switch.go b/core/server/web/company_switch.go index f155a221..9d7b4285 100644 --- a/core/server/web/company_switch.go +++ b/core/server/web/company_switch.go @@ -19,7 +19,7 @@ func SwitchCompanyPost(w http.ResponseWriter, r *http.Request) { form := parseCompanySwitchForm(r) userID := SessionUserID(r) - if userID <= 0 { + if 0 >= userID { http.Redirect(w, r, loginRoute, http.StatusSeeOther) return } @@ -42,7 +42,7 @@ func parseCompanySwitchForm(r *http.Request) companySwitchForm { } func switchActiveCompany(ctx context.Context, userID, companyID int) { - if companyID <= 0 || !companyRecordExists(ctx, companyID) { + if 0 >= companyID || !companyRecordExists(ctx, companyID) { auditCompanySwitchDeny(ctx, userID, companyID, "invalid company") return } @@ -88,12 +88,7 @@ func companyRecordExists(ctx context.Context, companyID int) bool { } func updateUserActiveCompany(ctx context.Context, userID, companyID int) error { - userTable := orm.MustQuotedTableName(coreUserModel) - _, err := orm.DB.ExecContext(ctx, - `UPDATE `+userTable+` SET company_id = $1 WHERE id = $2`, - companyID, userID, - ) - return err + return orm.SetUserActiveCompany(ctx, userID, companyID) } func redirectToWebNext(w http.ResponseWriter, r *http.Request, rawNext string) { diff --git a/core/server/web/content_handler.go b/core/server/web/content_handler.go index 7fafd390..3358ea6d 100644 --- a/core/server/web/content_handler.go +++ b/core/server/web/content_handler.go @@ -33,7 +33,7 @@ func ContentHandler(w http.ResponseWriter, r *http.Request) { } else { rec, err = orm.ResolveContentAttachment(ctx, 0, spec.resModel, spec.resField, spec.resID) } - if err != nil || len(rec) == 0 { + if err != nil || 0 == len(rec) { http.NotFound(w, r) return } @@ -57,7 +57,7 @@ func ContentHandler(w http.ResponseWriter, r *http.Request) { if decErr != nil { data = []byte(raw) } - if len(data) == 0 { + if 0 == len(data) { http.NotFound(w, r) return } @@ -102,7 +102,7 @@ func parseContentPath(path string) (contentPathSpec, bool) { switch len(parts) { case 1: id, err := strconv.Atoi(strings.TrimSpace(parts[0])) - if err != nil || id <= 0 { + if err != nil || 0 >= id { return contentPathSpec{}, false } return contentPathSpec{attachmentID: id}, true @@ -113,7 +113,7 @@ func parseContentPath(path string) (contentPathSpec, bool) { resModel := strings.TrimSpace(parts[0]) resField := strings.TrimSpace(parts[1]) resID, err := strconv.Atoi(strings.TrimSpace(parts[2])) - if resModel == "" || resField == "" || err != nil || resID <= 0 { + if resModel == "" || resField == "" || err != nil || 0 >= resID { return contentPathSpec{}, false } return contentPathSpec{resModel: resModel, resField: resField, resID: resID}, true @@ -123,7 +123,10 @@ func parseContentPath(path string) (contentPathSpec, bool) { } func contentInlineAllowed(mimeType string) bool { - m := strings.ToLower(strings.TrimSpace(mimeType)) + m := strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0])) + if m == "image/svg+xml" { + return false + } if strings.HasPrefix(m, "image/") { return true } diff --git a/core/server/web/csrf.go b/core/server/web/csrf.go index e7c6cb30..ca67aa50 100644 --- a/core/server/web/csrf.go +++ b/core/server/web/csrf.go @@ -33,7 +33,7 @@ func csrfKey() []byte { csrfSecretMu.Lock() defer csrfSecretMu.Unlock() - if len(csrfSecret) == 0 { + if 0 == len(csrfSecret) { if configured := strings.TrimSpace(config.AppConfig.CSRFSecret); configured != "" { csrfSecret = []byte(configured) } else { @@ -72,7 +72,7 @@ func CSRFTokenForRequest(r *http.Request) string { return hex.EncodeToString(mac.Sum(nil)[:16]) } -// ValidateCSRF checks csrf_token (form, header, or query on GET/HEAD) against the session-bound token. +// ValidateCSRF checks csrf_token (form or header) against the session-bound token. func ValidateCSRF(r *http.Request) bool { expected := CSRFTokenForRequest(r) if expected == "" { @@ -82,9 +82,6 @@ func ValidateCSRF(r *http.Request) bool { if got == "" { got = r.Header.Get(csrfHeaderName) } - if got == "" && (r.Method == http.MethodGet || r.Method == http.MethodHead) { - got = strings.TrimSpace(r.URL.Query().Get(csrfFormField)) - } return got != "" && hmac.Equal([]byte(got), []byte(expected)) } diff --git a/core/server/web/export_entitlement.go b/core/server/web/export_entitlement.go new file mode 100644 index 00000000..275208b0 --- /dev/null +++ b/core/server/web/export_entitlement.go @@ -0,0 +1,50 @@ +package web + +import ( + "net/http" + "strings" + + "sumeru/core/engine/swcmeta" + "sumeru/core/orm" + "sumeru/core/report" +) + +func requireExportEntitlement(w http.ResponseWriter, r *http.Request, modelName string, fields []string, format string) bool { + ctx := r.Context() + uid := orm.SecurityUID(ctx) + if err := orm.CheckFieldReadAccess(ctx, uid, modelName, fields); err != nil { + http.Error(w, forbiddenMessage, http.StatusForbidden) + return false + } + actionID := report.ParseActionIDParam(r.URL.Query().Get(actionIDField)) + if actionID <= 0 { + return true + } + row, err := loadWindowAction(ctx, actionID) + if err != nil { + http.Error(w, forbiddenMessage, http.StatusForbidden) + return false + } + resModel := strings.TrimSpace(orm.AsString(row["res_model"])) + if resModel != "" && resModel != modelName { + http.Error(w, forbiddenMessage, http.StatusForbidden) + return false + } + modes := splitViewModes(strings.TrimSpace(orm.AsString(row["view_mode"]))) + caps := swcmeta.ExportEntitlementsForModel(ctx, modelName, modes) + if !caps.HasDownload() { + http.Error(w, forbiddenMessage, http.StatusForbidden) + return false + } + format = strings.ToLower(strings.TrimSpace(format)) + if format == "" { + return true + } + for _, allowed := range caps.DownloadFormats { + if allowed == format { + return true + } + } + http.Error(w, forbiddenMessage, http.StatusForbidden) + return false +} diff --git a/core/server/web/export_handlers.go b/core/server/web/export_handlers.go index d6f523b4..b928773e 100644 --- a/core/server/web/export_handlers.go +++ b/core/server/web/export_handlers.go @@ -25,10 +25,14 @@ func resolveExportRequest(w http.ResponseWriter, r *http.Request) (report.Export return report.ExportCSVInput{}, false } fields := report.ParseFieldsParam(r.URL.Query().Get(reportFieldsParam)) - if len(fields) == 0 { + if 0 == len(fields) { http.Error(w, "fields required", http.StatusBadRequest) return report.ExportCSVInput{}, false } + exportFormat := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/web/export/"), "/") + if !requireExportEntitlement(w, r, modelName, fields, exportFormat) { + return report.ExportCSVInput{}, false + } domain := listExportDomain(r.Context(), r, modelName) recordID, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get(workspaceRecordIDParam))) return report.ExportCSVInput{ diff --git a/core/server/web/flash_cookies.go b/core/server/web/flash_cookies.go index c95fadd7..4819d778 100644 --- a/core/server/web/flash_cookies.go +++ b/core/server/web/flash_cookies.go @@ -7,8 +7,8 @@ import ( ) const ( - apiKeyFlashCookie = "sumeru_api_key_flash" - recordErrorFlashCookie = "sumeru_record_error_flash" + apiKeyFlashCookie = "sumeru_api_key_flash" + recordErrorFlashCookie = "sumeru_record_error_flash" ) type recordErrorFlashPayload struct { diff --git a/core/server/web/home_dashboard.go b/core/server/web/home_dashboard.go index e96904be..e423977d 100644 --- a/core/server/web/home_dashboard.go +++ b/core/server/web/home_dashboard.go @@ -33,7 +33,7 @@ func HomeDashboardHandler(w http.ResponseWriter, r *http.Request) { tiles = filterHomeTiles(tiles) emptyMessage := "" - if len(tiles) == 0 { + if 0 == len(tiles) { emptyMessage = homeEmptyMessage } @@ -51,8 +51,8 @@ func HomeDashboardHandler(w http.ResponseWriter, r *http.Request) { ViewBreadcrumb: "Dashboard", ViewStylesheetURLs: []string{homeStylesheetURL}, SuppressSidebar: true, - ViewTabs: render.HomeViewTabs(layout), - BreadcrumbItems: render.BuildHomeDashboardBreadcrumbs(ctx), + ViewTabs: render.HomeViewTabs(layout), + BreadcrumbItems: render.BuildHomeDashboardBreadcrumbs(ctx), }, }) WebLogNavigation(ctx, r.URL.Path, "module_hub", "Home dashboard opened", map[string]interface{}{ diff --git a/core/server/web/http_helpers.go b/core/server/web/http_helpers.go index 7bca959d..1a7d127b 100644 --- a/core/server/web/http_helpers.go +++ b/core/server/web/http_helpers.go @@ -24,7 +24,7 @@ func splitCommaSeparatedValues(raw string) []string { func firstGroupByField(raw string) string { fields := splitCommaSeparatedValues(raw) - if len(fields) == 0 { + if 0 == len(fields) { return "" } return fields[0] diff --git a/core/server/web/import_wizard_page.go b/core/server/web/import_wizard_page.go index eb75b4e1..c7045b98 100644 --- a/core/server/web/import_wizard_page.go +++ b/core/server/web/import_wizard_page.go @@ -13,20 +13,24 @@ func ImportWizardPageHandler(w http.ResponseWriter, r *http.Request) { if !requireLogin(w, r) { return } - batchID := strings.TrimSpace(r.URL.Query().Get("batch")) - if batchID == "" { + batchIDStr := strings.TrimSpace(r.URL.Query().Get("batch")) + if batchIDStr == "" { http.Error(w, "batch required", http.StatusBadRequest) return } - if _, err := strconv.Atoi(batchID); err != nil { + batchID, err := strconv.Atoi(batchIDStr) + if err != nil || 0 >= batchID { http.Error(w, "invalid batch", http.StatusBadRequest) return } + if _, ok := loadOwnedBulkBatch(w, r, batchID); !ok { + return + } renderShellPage(w, r, shellPageOpts{ Route: importWizardRoute, InnerTemplate: render.TemplatePagesImportWizard, InnerData: map[string]interface{}{ - "BatchID": batchID, + "BatchID": batchIDStr, "CSRFToken": CSRFTokenForRequest(r), }, Page: render.PageData{Title: "Import data"}, diff --git a/core/server/web/list_group.go b/core/server/web/list_group.go index d9f7d209..c975eb4c 100644 --- a/core/server/web/list_group.go +++ b/core/server/web/list_group.go @@ -41,7 +41,7 @@ func groupSectionLabel(key string, displayFromRow string) string { // partitionListSections groups flat list rows by the first group-by field value. func partitionListSections(rows []map[string]interface{}, groupField string) []render.ListSection { - if groupField == "" || len(rows) == 0 { + if groupField == "" || 0 == len(rows) { return nil } buckets := map[string][]map[string]interface{}{} diff --git a/core/server/web/list_search.go b/core/server/web/list_search.go index 798f88c9..f978e799 100644 --- a/core/server/web/list_search.go +++ b/core/server/web/list_search.go @@ -40,12 +40,12 @@ func listSearchFieldNames(views ...*parser.View) []string { } type listDomainInput struct { - ActionData map[string]interface{} - View *parser.View - SearchView *parser.View - SearchQuery string - FilterCSV string - DomainJSON string + ActionData map[string]interface{} + View *parser.View + SearchView *parser.View + SearchQuery string + FilterCSV string + DomainJSON string } func workspaceListDomain(ctx context.Context, in listDomainInput) [][]interface{} { @@ -66,9 +66,13 @@ func workspaceListDomain(ctx context.Context, in listDomainInput) [][]interface{ continue } dom, err := orm.ParseDomainJSON(f.Domain) - if err != nil || len(dom) == 0 { + if err != nil { continue } + if 0 == len(dom) { + continue + } + dom = orm.ResolveDomainXMLRefs(ctx, dom) dom = orm.SubstituteDomainUID(dom, uid) base = orm.MergeDomains(base, dom) @@ -86,7 +90,7 @@ func workspaceListDomain(ctx context.Context, in listDomainInput) [][]interface{ } func filterDomainToModel(model string, domain [][]interface{}) [][]interface{} { - if model == "" || len(domain) == 0 { + if model == "" || 0 == len(domain) { return domain } inst, ok := orm.Registry[model] diff --git a/core/server/web/login.go b/core/server/web/login.go index c2077475..eed8e589 100644 --- a/core/server/web/login.go +++ b/core/server/web/login.go @@ -13,8 +13,6 @@ import ( "sync" "time" - "golang.org/x/crypto/bcrypt" - "sumeru/core/applog" "sumeru/core/engine/assets" "sumeru/core/engine/render" @@ -59,20 +57,11 @@ type loginFinishOpts struct { var ( loginLockoutMu sync.Mutex loginFailures = map[string][]time.Time{} - dummyPasswordHash = mustDummyBcryptHash() authTemplateMu sync.Mutex authTemplateCache = map[string]*template.Template{} authTemplateErrs = map[string]error{} ) -func mustDummyBcryptHash() string { - h, err := bcrypt.GenerateFromPassword([]byte("sumeru-timing-dummy-password"), bcrypt.DefaultCost) - if err != nil { - panic("login lockout: dummy bcrypt: " + err.Error()) - } - return string(h) -} - func setLoginCSRFCookie(w http.ResponseWriter) string { token := newLoginCSRFToken() setNamedCookie(w, loginCSRFCookie, token, loginRoute, 600, http.SameSiteStrictMode) @@ -185,12 +174,7 @@ func resetLoginLockoutState() { } func comparePasswordConstantTime(storedHash, plain string) bool { - storedHash = strings.TrimSpace(storedHash) - if storedHash == "" { - _ = bcrypt.CompareHashAndPassword([]byte(dummyPasswordHash), []byte(plain)) - return false - } - return bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(plain)) == nil + return auth.ComparePasswordHash(storedHash, plain) } func parsePositiveInt(s string) int { @@ -305,7 +289,7 @@ func TOTPLoginPost(w http.ResponseWriter, r *http.Request) { return } uid := pendingMFAUserID(r) - if uid <= 0 { + if 0 >= uid { http.Error(w, "session expired", http.StatusUnauthorized) return } @@ -397,7 +381,7 @@ func finishTOTPSession(w http.ResponseWriter, r *http.Request, uid int, nextFrom } func establishSessionAndRedirect(w http.ResponseWriter, r *http.Request, userID int, next, logRoute string) { - if err := CreateSession(w, userID); err != nil { + if err := CreateSession(w, r.Context(), userID); err != nil { WebLogEvent(r.Context(), WebLogInput{ Route: logRoute, Message: "Could not start session", @@ -518,25 +502,12 @@ func parseLoginCredentials(r *http.Request) loginCredentials { } func verifyLoginCredentials(ctx context.Context, credentials loginCredentials, clientIP string) (int, bool) { - var userID int - var passwordHash string - var active bool - userTbl := orm.MustQuotedTableName(coreUserModel) - err := orm.DB.QueryRowContext(ctx, - `SELECT id, COALESCE(password, ''), active FROM `+userTbl+` WHERE LOWER(TRIM(login)) = LOWER(TRIM($1)) LIMIT 1`, - credentials.Login, - ).Scan(&userID, &passwordHash, &active) - if err != nil || !active { - comparePasswordConstantTime("", credentials.Password) + userID, _, _, err := orm.AuthenticateLocalUser(ctx, credentials.Login, credentials.Password) + if err != nil { recordFailedLogin(ctx, 0, clientIP, "login="+credentials.Login) recordLoginFailure(credentials.Login) return 0, false } - if !comparePasswordConstantTime(passwordHash, credentials.Password) { - recordFailedLogin(ctx, userID, clientIP, "bad password") - recordLoginFailure(credentials.Login) - return 0, false - } return userID, true } diff --git a/core/server/web/login_brand_logo.go b/core/server/web/login_brand_logo.go index d72c3413..4564f0bf 100644 --- a/core/server/web/login_brand_logo.go +++ b/core/server/web/login_brand_logo.go @@ -41,7 +41,7 @@ func parseLoginLogoDataURL(raw string) (mime string, data []byte, ok bool) { } rest := strings.TrimPrefix(raw, "data:") semi := strings.Index(rest, ";") - if semi <= 0 { + if 0 >= semi { return "", nil, false } mime = strings.TrimSpace(rest[:semi]) @@ -56,7 +56,7 @@ func parseLoginLogoDataURL(raw string) (mime string, data []byte, ok bool) { if err != nil { return "", nil, false } - if len(decoded) == 0 { + if 0 == len(decoded) { return "", nil, false } return mime, decoded, true diff --git a/core/server/web/login_branding.go b/core/server/web/login_branding.go index 348760f4..c04a5bdb 100644 --- a/core/server/web/login_branding.go +++ b/core/server/web/login_branding.go @@ -11,11 +11,11 @@ import ( ) const ( - defaultSumeruLogoURL = "/static/img/sumeru-logo.svg" - loginBrandLogoRoute = "/web/login/logo" - loginBrandPlatformName = "Sumeru" - defaultLoginHeadline = "Enterprise workspace" - defaultLoginTagline = "Role-based access, audit trails, and optional single sign-on for your organization." + defaultSumeruLogoURL = "/static/img/sumeru-logo.svg" + loginBrandLogoRoute = "/web/login/logo" + loginBrandPlatformName = "Sumeru" + defaultLoginHeadline = "Enterprise workspace" + defaultLoginTagline = "Role-based access, audit trails, and optional single sign-on for your organization." defaultLoginTenantTagline = "Sign in to continue to your workspace." ) @@ -110,9 +110,13 @@ func buildLoginBrand(row loginCompanyRow) loginBrand { func loadLoginCompanyRow(ctx context.Context) loginCompanyRow { ctx = orm.AuditedBypass(ctx, "login.brand") rows, err := orm.SearchLimit(ctx, "core.company", nil, 1) - if err != nil || len(rows) == 0 { + if err != nil { return loginCompanyRow{} } + if 0 == len(rows) { + return loginCompanyRow{} + } + rec := rows[0] return loginCompanyRow{ Name: oauthRowString(rec, "name"), diff --git a/core/server/web/oauth_login.go b/core/server/web/oauth_login.go index 908bda4e..930e0c5f 100644 --- a/core/server/web/oauth_login.go +++ b/core/server/web/oauth_login.go @@ -31,7 +31,7 @@ func registerOAuthRoutes() { func OAuthStartHandler(w http.ResponseWriter, r *http.Request) { providerID := parsePositiveInt(r.URL.Query().Get("provider")) - if providerID <= 0 { + if 0 >= providerID { http.Error(w, "provider required", http.StatusBadRequest) return } @@ -52,7 +52,12 @@ func OAuthStartHandler(w http.ResponseWriter, r *http.Request) { q := url.Values{} q.Set("response_type", "code") q.Set("client_id", oauthRowString(prov, "client_id")) - q.Set("redirect_uri", oauthRedirectURI(r)) + redirectURI, err := oauthRedirectURI(r) + if err != nil { + redirectLoginOAuthError(w, r) + return + } + q.Set("redirect_uri", redirectURI) q.Set("scope", defaultScope(oauthRowString(prov, "scopes"))) q.Set("state", state) q.Set("code_challenge", challenge) @@ -83,12 +88,17 @@ func OAuthCallbackHandler(w http.ResponseWriter, r *http.Request) { if tokenURL == "" { tokenURL = strings.TrimRight(strings.TrimSpace(oauthRowString(prov, "issuer_url")), "/") + "/token" } - tok, err := exchangeOAuthCode(r, tokenURL, oauthRowString(prov, "client_id"), oauthRowString(prov, "client_secret"), code, verifier, oauthRedirectURI(r)) + redirectURI, err := oauthRedirectURI(r) + if err != nil { + redirectLoginOAuthError(w, r) + return + } + tok, err := exchangeOAuthCode(r, tokenURL, oauthRowString(prov, "client_id"), oauthRowString(prov, "client_secret"), code, verifier, redirectURI) if err != nil { redirectLoginOAuthError(w, r) return } - email, sub, err := resolveOAuthIdentity(r.Context(), prov, tok) + email, sub, emailVerified, err := resolveOAuthIdentity(r.Context(), prov, tok) if err != nil { redirectLoginOAuthError(w, r) return @@ -97,8 +107,8 @@ func OAuthCallbackHandler(w http.ResponseWriter, r *http.Request) { redirectLoginOAuthError(w, r) return } - userID, err := linkOAuthUser(ctx, providerID, sub, email, oauthRowString(prov, "link_policy")) - if err != nil || userID <= 0 { + userID, err := linkOAuthUser(ctx, providerID, sub, email, emailVerified, oauthRowString(prov, "link_policy")) + if err != nil || 0 >= userID { redirectLoginOAuthError(w, r) return } @@ -140,7 +150,7 @@ func exchangeOAuthCode(r *http.Request, tokenURL, clientID, clientSecret, code, return out, nil } -func linkOAuthUser(ctx context.Context, providerID int, subject, email, policy string) (int, error) { +func linkOAuthUser(ctx context.Context, providerID int, subject, email string, emailVerified bool, policy string) (int, error) { subject = strings.TrimSpace(subject) if subject == "" { return 0, fmt.Errorf("missing subject") @@ -152,7 +162,7 @@ func linkOAuthUser(ctx context.Context, providerID int, subject, email, policy s return int(oauthRowInt(rec, "user_id")), nil } email = strings.TrimSpace(strings.ToLower(email)) - if email != "" { + if email != "" && emailVerified { if user, err := orm.SearchOne(ctx, "core.user", map[string]interface{}{"login": email}); err == nil && user != nil { uid := int(oauthRowInt(user, "id")) _ = upsertIdentity(ctx, providerID, subject, uid) @@ -210,22 +220,28 @@ func validateOAuthIdentityClaims(prov map[string]interface{}, tok map[string]int return nil } -func resolveOAuthIdentity(ctx context.Context, prov map[string]interface{}, tok map[string]interface{}) (email, sub string, err error) { +func resolveOAuthIdentity(ctx context.Context, prov map[string]interface{}, tok map[string]interface{}) (email, sub string, emailVerified bool, err error) { idTok, _ := tok["id_token"].(string) jwksURL := strings.TrimSpace(oauthRowString(prov, "jwks_url")) issuer := strings.TrimSpace(oauthRowString(prov, "issuer_url")) clientID := strings.TrimSpace(oauthRowString(prov, "client_id")) - if strings.TrimSpace(idTok) != "" && jwksURL != "" { + idTok = strings.TrimSpace(idTok) + if idTok != "" && jwksURL != "" { claims, verr := auth.VerifyIDToken(ctx, idTok, issuer, clientID, jwksURL) if verr != nil { - return "", "", verr + return "", "", false, verr } email, _ = claims["email"].(string) sub, _ = claims["sub"].(string) - return email, sub, nil + return email, sub, oauthEmailVerified(claims), nil + } + if !config.AppConfig.DevMode && (idTok != "" || jwksURL != "") { + return "", "", false, fmt.Errorf("oidc verification required") } - if strings.TrimSpace(idTok) != "" { - email, sub, _ = auth.ParseIDTokenClaimsUnsafe(idTok) + var claims map[string]interface{} + if idTok != "" { + email, sub, claims = auth.ParseIDTokenClaimsUnsafe(idTok) + emailVerified = oauthEmailVerified(claims) } if sub == "" { sub, _ = tok["sub"].(string) @@ -239,9 +255,23 @@ func resolveOAuthIdentity(ctx context.Context, prov map[string]interface{}, tok } } if sub == "" { - return "", "", fmt.Errorf("missing subject") + return "", "", false, fmt.Errorf("missing subject") + } + return email, sub, emailVerified, nil +} + +func oauthEmailVerified(claims map[string]interface{}) bool { + if claims == nil { + return false + } + switch v := claims["email_verified"].(type) { + case bool: + return v + case string: + return strings.EqualFold(strings.TrimSpace(v), "true") + default: + return false } - return email, sub, nil } func fetchOAuthUserInfo(ctx context.Context, accessToken, issuer string) (email, sub string) { @@ -284,13 +314,17 @@ type loginAuthProvider struct { func listEnabledAuthProviders(ctx context.Context) []loginAuthProvider { ctx = orm.AuditedBypass(ctx, "login.providers") rows, err := orm.Search(ctx, "sys.auth.provider", [][]interface{}{{"enabled", "=", true}}) - if err != nil || len(rows) == 0 { + if err != nil { return nil } + if 0 == len(rows) { + return nil + } + out := make([]loginAuthProvider, 0, len(rows)) for _, row := range rows { id := int(oauthRowInt(row, "id")) - if id <= 0 { + if 0 >= id { continue } label := strings.TrimSpace(oauthRowString(row, "button_label")) @@ -310,7 +344,7 @@ func authLocalEnabled(ctx context.Context) bool { switch raw { case "0", "false", "no", "off": providers := listEnabledAuthProviders(ctx) - return len(providers) == 0 + return 0 == len(providers) default: return true } @@ -319,9 +353,13 @@ func authLocalEnabled(ctx context.Context) bool { func loginPageCompanyName(ctx context.Context) string { ctx = orm.AuditedBypass(ctx, "login.brand") rows, err := orm.SearchLimit(ctx, "core.company", nil, 1) - if err != nil || len(rows) == 0 { + if err != nil { return "" } + if 0 == len(rows) { + return "" + } + return strings.TrimSpace(oauthRowString(rows[0], "name")) } @@ -336,14 +374,6 @@ func randomURLSafe(n int) string { return base64.RawURLEncoding.EncodeToString(b) } -func oauthRedirectURI(r *http.Request) string { - scheme := "http" - if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { - scheme = "https" - } - return scheme + "://" + r.Host + oauthCallbackRoute -} - func defaultScope(s string) string { s = strings.TrimSpace(s) if s == "" { diff --git a/core/server/web/portal_handlers.go b/core/server/web/portal_handlers.go index 1826068e..10d68084 100644 --- a/core/server/web/portal_handlers.go +++ b/core/server/web/portal_handlers.go @@ -130,7 +130,7 @@ func PortalRecordHandler(w http.ResponseWriter, r *http.Request) { } model := strings.TrimSpace(r.URL.Query().Get("model")) id, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("id")), 10, 64) - if model == "" || id <= 0 || !orm.PortalAllowsModel(model, "read") { + if model == "" || 0 >= id || !orm.PortalAllowsModel(model, "read") { http.NotFound(w, r) return } @@ -190,7 +190,7 @@ func requirePortalUser(w http.ResponseWriter, r *http.Request) bool { return false } uid := SessionUserID(r) - if uid <= 0 { + if 0 >= uid { return false } if !orm.UserIsPortalOnly(r.Context(), uid) && !orm.UserHasGroupXML(r.Context(), uid, "base.group_user") { @@ -201,7 +201,7 @@ func requirePortalUser(w http.ResponseWriter, r *http.Request) bool { } func redirectPortalUserFromWeb(w http.ResponseWriter, r *http.Request, uid int) bool { - if uid <= 0 || !orm.UserIsPortalOnly(r.Context(), uid) { + if 0 >= uid || !orm.UserIsPortalOnly(r.Context(), uid) { return false } http.Redirect(w, r, portalHomeRoute, http.StatusFound) diff --git a/core/server/web/public_url.go b/core/server/web/public_url.go new file mode 100644 index 00000000..38a1a6fb --- /dev/null +++ b/core/server/web/public_url.go @@ -0,0 +1,38 @@ +package web + +import ( + "fmt" + "net/http" + "strings" + + "sumeru/core/server/config" +) + +// publicBaseURL returns the configured public origin or derives it from a trusted request. +func publicBaseURL(r *http.Request) (string, error) { + configured := strings.TrimRight(strings.TrimSpace(config.AppConfig.WebBaseURL), "/") + if configured != "" { + return configured, nil + } + peer := remoteAddrIP(r) + if !isLoopbackIP(peer) && !trustedProxyContains(peer) { + return "", fmt.Errorf("web_base_url required when setup is not from localhost or a trusted proxy") + } + scheme := "http" + if r.TLS != nil || strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") { + scheme = "https" + } + host := strings.TrimSpace(r.Host) + if host == "" { + return "", fmt.Errorf("missing host") + } + return scheme + "://" + host, nil +} + +func oauthRedirectURI(r *http.Request) (string, error) { + base, err := publicBaseURL(r) + if err != nil { + return "", err + } + return base + oauthCallbackRoute, nil +} diff --git a/core/server/web/query_flash.go b/core/server/web/query_flash.go index 8e59a587..6bd85826 100644 --- a/core/server/web/query_flash.go +++ b/core/server/web/query_flash.go @@ -61,6 +61,12 @@ func FlashFromQueryMessage(msg string) (render.FlashMessage, bool) { return render.FlashMessage{Kind: "error", Title: "Password required", Body: "Enter a new password."}, true case "password_failed": return render.FlashMessage{Kind: "error", Title: "Could not update password", Body: "Check policy requirements and try again."}, true + case "auth_settings_saved": + return render.FlashMessage{Kind: "success", Title: "Authentication settings saved", Body: "System parameters were updated."}, true + case "auth_settings_invalid": + return render.FlashMessage{Kind: "error", Title: "Invalid settings", Body: "Check password minimum length (6–72) and try again."}, true + case "auth_settings_failed": + return render.FlashMessage{Kind: "error", Title: "Could not save settings", Body: "Try again or contact your administrator."}, true case "field_acl_saved": return render.FlashMessage{Kind: "success", Title: "Field access saved", Body: "Matrix rules were updated."}, true case "field_acl_failed": diff --git a/core/server/web/rate_limit.go b/core/server/web/rate_limit.go index 18fc2118..15927093 100644 --- a/core/server/web/rate_limit.go +++ b/core/server/web/rate_limit.go @@ -2,10 +2,12 @@ package web import ( "net/http" + "strconv" "strings" "sync" "time" + "sumeru/core/server/api" "sumeru/core/server/config" ) @@ -17,6 +19,7 @@ type rateBucket struct { var ( rateMu sync.Mutex rateByIP = map[string]*rateBucket{} + rateByUID = map[string]*rateBucket{} rateLimitOn bool rateLimitRPM int ) @@ -28,28 +31,26 @@ func InitRateLimit() { } func rateLimitedPath(path string) bool { + if strings.HasPrefix(path, apiRPCRoutePrefix) { + return true + } switch path { - case apiRPCRoute, loginRoute, totpLoginRoute, oauthStartRoute, oauthCallbackRoute: + case loginRoute, totpLoginRoute, oauthStartRoute, oauthCallbackRoute: return true default: return false } } -func allowRate(clientIP string) bool { - if !rateLimitOn { - return true - } - clientIP = strings.TrimSpace(clientIP) - if clientIP == "" { - clientIP = "unknown" +func allowRateBucket(buckets map[string]*rateBucket, key string) bool { + key = strings.TrimSpace(key) + if key == "" { + key = "unknown" } now := time.Now() - rateMu.Lock() - defer rateMu.Unlock() - b, ok := rateByIP[clientIP] + b, ok := buckets[key] if !ok || now.Sub(b.window) >= time.Minute { - rateByIP[clientIP] = &rateBucket{count: 1, window: now} + buckets[key] = &rateBucket{count: 1, window: now} return true } if b.count >= rateLimitRPM { @@ -59,17 +60,60 @@ func allowRate(clientIP string) bool { return true } +func allowRate(clientIP string, uid int) bool { + if !rateLimitOn { + return true + } + rateMu.Lock() + defer rateMu.Unlock() + if !allowRateBucket(rateByIP, clientIP) { + return false + } + if uid > 0 { + return allowRateBucket(rateByUID, strconv.Itoa(uid)) + } + return true +} + +func writeRateLimitResponse(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, apiRPCRoutePrefix) { + api.WriteResponse(w, http.StatusTooManyRequests, api.Fail(api.CodeRateLimited, "Too many requests", nil)) + return + } + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) +} + func enforceRateLimit(w http.ResponseWriter, r *http.Request) bool { if !rateLimitedPath(r.URL.Path) { return true } - if allowRate(clientIP(r)) { + uid := SessionUserID(r) + if allowRate(clientIP(r), uid) { return true } - http.Error(w, "Too Many Requests", http.StatusTooManyRequests) + writeRateLimitResponse(w, r) return false } +// ResetRateLimitForTest clears in-memory rate limit buckets (tests only). +func ResetRateLimitForTest() { + rateMu.Lock() + defer rateMu.Unlock() + rateByIP = map[string]*rateBucket{} + rateByUID = map[string]*rateBucket{} +} + +// SetRateLimitRPMForTest configures RPM and enables limiting (tests only). +func SetRateLimitRPMForTest(rpm int) { + rateLimitRPM = rpm + rateLimitOn = rpm > 0 +} + +// EnforceRateLimitForTest exposes enforceRateLimit for handler tests. +func EnforceRateLimitForTest(w http.ResponseWriter, r *http.Request) bool { + return enforceRateLimit(w, r) +} + // pruneAttemptsAfter keeps timestamps strictly after cutoff (in-place slice reuse). func pruneAttemptsAfter(attempts []time.Time, cutoff time.Time) []time.Time { out := attempts[:0] diff --git a/core/server/web/report_routes.go b/core/server/web/report_routes.go index 086379ac..22582cc6 100644 --- a/core/server/web/report_routes.go +++ b/core/server/web/report_routes.go @@ -10,10 +10,10 @@ import ( ) const ( - reportPrintRoute = "/web/report/print" - reportPreviewRoute = "/web/report/preview" - exportPivotRoute = "/web/export/pivot" - exportGraphRoute = "/web/export/graph" + reportPrintRoute = "/web/report/print" + reportPreviewRoute = "/web/report/preview" + exportPivotRoute = "/web/export/pivot" + exportGraphRoute = "/web/export/graph" ) func registerReportRoutes() { @@ -93,9 +93,12 @@ func ExportPivotHandler(w http.ResponseWriter, r *http.Request) { if !requireModelAccess(w, r, modelName, "read") { return } + if !requireExportEntitlement(w, r, modelName, nil, "csv") { + return + } groupFields := report.ParseFieldsParam(r.URL.Query().Get("group_by")) measures := report.ParseFieldsParam(r.URL.Query().Get("measures")) - if len(groupFields) == 0 || len(measures) == 0 { + if 0 == len(groupFields) || 0 == len(measures) { http.Error(w, "group_by and measures required", http.StatusBadRequest) return } @@ -131,6 +134,9 @@ func ExportGraphHandler(w http.ResponseWriter, r *http.Request) { if !requireModelAccess(w, r, modelName, "read") { return } + if !requireExportEntitlement(w, r, modelName, nil, "csv") { + return + } groupField := strings.TrimSpace(r.URL.Query().Get("group_by")) measureField := strings.TrimSpace(r.URL.Query().Get("measure")) if groupField == "" || measureField == "" { diff --git a/core/server/web/routes_table.go b/core/server/web/routes_table.go index 4a4f64fb..3926287f 100644 --- a/core/server/web/routes_table.go +++ b/core/server/web/routes_table.go @@ -40,6 +40,7 @@ func RegisterAppRoutes(mux *http.ServeMux) { registerAppAliases(serveMux) registerContentPrefixRoute(serveMux) registerPortalSharePrefixRoute(serveMux) + registerAPIRPCPrefixRoute(serveMux) } func registerContentPrefixRoute(mux *http.ServeMux) { @@ -105,6 +106,7 @@ func registerActionRoutes() { func registerSettingsRoutes() { registerSession(http.MethodGet, settingsRoute, SettingsHubHandler) registerSettingsAccountRoutes() + registerSettingsPlatformRoutes() registerSettingsFieldACLRoutes() registerSettingsModelACLRoutes() registerSession(http.MethodGet, appLogsRoute, AppLogsHandler) @@ -114,7 +116,10 @@ func registerSettingsRoutes() { func registerAPIRoutes() { registerPublic(http.MethodGet, apiHealthRoute, APIHealthHandler) registerPublic(http.MethodGet, apiReadyRoute, APIReadyHandler) - registerPublic(http.MethodPost, apiRPCRoute, RPCJSONHandler) +} + +func registerAPIRPCPrefixRoute(mux *http.ServeMux) { + mux.HandleFunc(apiRPCRoutePrefix, RPCPrefixHandler) } func registerPortalRoutes() { diff --git a/core/server/web/rpc_auth.go b/core/server/web/rpc_auth.go new file mode 100644 index 00000000..f8c832fd --- /dev/null +++ b/core/server/web/rpc_auth.go @@ -0,0 +1,100 @@ +package web + +import ( + "encoding/json" + "net/http" + "strings" + + "sumeru/core/applog" + "sumeru/core/errcode" + "sumeru/core/orm" + "sumeru/core/server/api" + "sumeru/core/server/config" +) + +type authLoginRequest struct { + Context map[string]interface{} `json:"context"` + Login string `json:"login"` + Password string `json:"password"` +} + +func handleRPCAuthLogin(w http.ResponseWriter, r *http.Request, requestBody []byte, companyID int64) { + if !authLocalEnabled(r.Context()) { + api.WriteResponse(w, http.StatusForbidden, api.AuthFail(http.StatusForbidden, "AUTH_DISABLED", "AuthenticationError", "Password sign-in is disabled", "")) + return + } + var in authLoginRequest + if err := json.Unmarshal(requestBody, &in); err != nil { + api.WriteResponse(w, http.StatusBadRequest, api.Fail(api.CodeInvalidJSON, "invalid json", nil)) + return + } + login := strings.TrimSpace(in.Login) + if login == "" || in.Password == "" { + api.WriteResponse(w, http.StatusBadRequest, api.Fail(api.CodeValidationError, "login and password are required", nil)) + return + } + clientIP := clientIP(r) + if loginLocked(login) { + writeAuthLoginFailure(w, r, login, clientIP, 0) + return + } + uid, username, name, err := orm.AuthenticateLocalUser(r.Context(), login, in.Password) + if err != nil { + writeAuthLoginFailure(w, r, login, clientIP, 0) + return + } + if !orm.UserAllowedCompany(r.Context(), uid, companyID) { + api.WriteResponse(w, http.StatusForbidden, api.AuthFail(http.StatusForbidden, "TENANT_DENIED", "AuthenticationError", "Authentication failed", authDebugMessage("tenant not allowed"))) + return + } + if needsTOTP(r, uid) { + api.WriteResponse(w, http.StatusForbidden, api.AuthFail(http.StatusForbidden, "MFA_REQUIRED", "AuthenticationError", "Multi-factor authentication required", authDebugMessage("totp required"))) + return + } + clearLoginFailures(login) + if clientIP != "" { + orm.AppendUserLog(r.Context(), uid, clientIP, "success") + } + sessionID, err := orm.CreateUserSession(r.Context(), uid, orm.SessionCreateOpts{StampBrowserSeen: sessionBrowserPresenceEnabled()}) + if err != nil { + api.WriteResponse(w, http.StatusInternalServerError, api.Fail(api.CodeInternalError, "Could not start session", nil)) + return + } + setSessionCookie(w, sessionID, false) + rawKey, err := orm.IssueAPIKeyForLogin(r.Context(), uid) + if err != nil { + api.WriteResponse(w, http.StatusInternalServerError, api.Fail(api.CodeInternalError, "Could not issue api key", nil)) + return + } + data := map[string]interface{}{ + "uid": uid, + "username": username, + "name": name, + "session_id": sessionID, + "api_key": rawKey, + } + resp := api.SuccessWithMessage(data, "User authenticated successfully") + api.WriteResponse(w, http.StatusOK, resp) +} + +func writeAuthLoginFailure(w http.ResponseWriter, r *http.Request, login, clientIP string, userID int) { + recordFailedLogin(r.Context(), userID, clientIP, "login="+login) + recordLoginFailure(login) + applog.WarnCode(r.Context(), errcode.InvalidCredentials, "Invalid login or password", applog.Event{ + Component: "rpc", + Operation: "auth.login", + Status: "failure", + }) + debug := authDebugMessage("Authentication failed: invalid username or password.") + api.WriteResponse(w, http.StatusUnauthorized, api.AuthFail(http.StatusUnauthorized, "INVALID_CREDENTIALS", "AuthenticationError", "Authentication failed", debug)) +} + +func authDebugMessage(msg string) string { + if config.AppConfig.DevMode { + return msg + } + if msg == "" { + return "Authentication failed." + } + return "Authentication failed." +} diff --git a/core/server/web/rpc_headers.go b/core/server/web/rpc_headers.go new file mode 100644 index 00000000..bb7f3132 --- /dev/null +++ b/core/server/web/rpc_headers.go @@ -0,0 +1,57 @@ +package web + +import ( + "net/http" + "strings" + + "sumeru/core/orm" + "sumeru/core/server/api" + "sumeru/core/server/config" +) + +const ( + sumeruDatabaseHeader = "X-Sumeru-Database" + sumeruTenantHeader = "X-Sumeru-Tenant" + rpcAuthLoginSegment = "auth.login" +) + +func validateRPCDatabaseHeader(r *http.Request) (ok bool, resp api.APIResponse, status int) { + got := strings.TrimSpace(r.Header.Get(sumeruDatabaseHeader)) + want := strings.TrimSpace(config.AppConfig.DbName) + if got == "" { + return false, api.Fail(api.CodeValidationError, "X-Sumeru-Database is required", map[string]interface{}{"header": sumeruDatabaseHeader}), http.StatusBadRequest + } + if want == "" || got != want { + return false, api.Fail(api.CodeValidationError, "Invalid database", map[string]interface{}{"header": sumeruDatabaseHeader}), http.StatusBadRequest + } + return true, api.APIResponse{}, 0 +} + +func resolveRPCTenantCompanyID(r *http.Request) (companyID int64, ok bool, resp api.APIResponse, status int) { + tenant := strings.TrimSpace(r.Header.Get(sumeruTenantHeader)) + if tenant == "" { + return 0, false, api.Fail(api.CodeValidationError, "X-Sumeru-Tenant is required", map[string]interface{}{"header": sumeruTenantHeader}), http.StatusBadRequest + } + cid, err := orm.ResolveCompanyTenant(r.Context(), tenant) + if err != nil { + return 0, false, api.Fail(api.CodeValidationError, err.Error(), map[string]interface{}{"header": sumeruTenantHeader}), http.StatusBadRequest + } + return cid, true, api.APIResponse{}, 0 +} + +func ensureUserAllowedTenant(r *http.Request, uid int, companyID int64) (ok bool, resp api.APIResponse, status int) { + if uid <= 0 { + return false, api.Fail(api.CodeUnauthorized, "Unauthorized", nil), http.StatusUnauthorized + } + if !orm.UserAllowedCompany(r.Context(), uid, companyID) { + return false, api.Fail(api.CodeAccessDenied, "Tenant not allowed for user", map[string]interface{}{"company_id": companyID}), http.StatusForbidden + } + return true, api.APIResponse{}, 0 +} + +func rpcUnauthorizedMessage(r *http.Request) (message string, invalidKey bool) { + if apiKeyFromRequest(r) != "" { + return "Invalid apikey", true + } + return "Unauthorized", false +} diff --git a/core/server/web/rpc_json.go b/core/server/web/rpc_json.go index b17c8f5f..ed5697b7 100644 --- a/core/server/web/rpc_json.go +++ b/core/server/web/rpc_json.go @@ -40,48 +40,163 @@ func APIReadyHandler(w http.ResponseWriter, r *http.Request) { writeJSONOK(w) } -// RPCJSONHandler is model RPC: POST JSON {"model","method","args","kwargs"} with session or API key auth. -func RPCJSONHandler(w http.ResponseWriter, r *http.Request) { +// RPCPrefixHandler routes POST /api/rpc/{model}/{method}, GET .../health, and POST auth.login. +func RPCPrefixHandler(w http.ResponseWriter, r *http.Request) { start := time.Now() metrics.Inc(rpcMetricRequests) defer func() { metrics.ObserveDuration(rpcMetricDuration, time.Since(start)) }() + if !strings.HasPrefix(r.URL.Path, apiRPCRoutePrefix) { + api.WriteResponse(w, http.StatusNotFound, api.Fail(api.CodeNotFound, "Not found", nil)) + return + } + remainder := strings.TrimPrefix(r.URL.Path, apiRPCRoutePrefix) + remainder = strings.Trim(remainder, "/") + if remainder == "" || strings.Contains(remainder, "..") { + api.WriteResponse(w, http.StatusNotFound, api.Fail(api.CodeNotFound, "Not found", nil)) + return + } + + if ok, resp, status := validateRPCDatabaseHeader(r); !ok { + api.WriteResponse(w, status, resp) + return + } + companyID, ok, resp, status := resolveRPCTenantCompanyID(r) + if !ok { + api.WriteResponse(w, status, resp) + return + } + + if remainder == rpcAuthLoginSegment { + if r.Method != http.MethodPost { + api.WriteResponse(w, http.StatusMethodNotAllowed, api.Fail(api.CodeMethodNotAllowed, "Method not allowed", nil)) + return + } + if apiKeyFromRequest(r) != "" || SessionUserID(r) > 0 { + api.WriteResponse(w, http.StatusBadRequest, api.Fail(api.CodeValidationError, "Authorization not allowed on auth.login", nil)) + return + } + body, readOK := readRPCBody(w, r) + if !readOK { + return + } + handleRPCAuthLogin(w, r, body, companyID) + return + } + + if strings.HasSuffix(remainder, "/health") { + parts := strings.Split(remainder, "/") + if len(parts) != 2 || parts[1] != "health" { + api.WriteResponse(w, http.StatusNotFound, api.Fail(api.CodeNotFound, "Not found", nil)) + return + } + if r.Method != http.MethodGet { + api.WriteResponse(w, http.StatusMethodNotAllowed, api.Fail(api.CodeMethodNotAllowed, "Method not allowed", nil)) + return + } + handleRPCModelHealth(w, r, parts[0], companyID) + return + } + if r.Method != http.MethodPost { api.WriteResponse(w, http.StatusMethodNotAllowed, api.Fail(api.CodeMethodNotAllowed, "Method not allowed", nil)) return } + slash := strings.LastIndex(remainder, "/") + if slash <= 0 || slash >= len(remainder)-1 { + api.WriteResponse(w, http.StatusNotFound, api.Fail(api.CodeNotFound, "Not found", nil)) + return + } + modelName := remainder[:slash] + methodName := remainder[slash+1:] + if !api.PublicMethods[methodName] { + api.WriteResponse(w, http.StatusNotFound, api.Fail(api.CodeMethodNotAllowed, "Method not allowed", map[string]interface{}{"method": methodName})) + return + } + userID := AuthenticatedUserID(r) if userID <= 0 { - api.WriteResponse(w, http.StatusUnauthorized, api.Fail(api.CodeUnauthorized, "Unauthorized", nil)) + msg, invalidKey := rpcUnauthorizedMessage(r) + if invalidKey { + api.WriteResponse(w, http.StatusUnauthorized, api.Fail(api.CodeUnauthorized, msg, nil)) + } else { + api.WriteResponse(w, http.StatusUnauthorized, api.Fail(api.CodeUnauthorized, msg, nil)) + } + return + } + if ok, resp, status := ensureUserAllowedTenant(r, userID, companyID); !ok { + api.WriteResponse(w, status, resp) return } - // Cookie sessions require CSRF; API keys are not browser cookie auth. if AuthViaSession(r) && !ValidateCSRF(r) { metrics.Inc("sumeru_csrf_rejected_total") api.WriteResponse(w, http.StatusForbidden, api.Fail(api.CodeAccessDenied, "Invalid CSRF token", nil)) return } - if !acceptsJSONContentType(r.Header.Get("Content-Type")) { - api.WriteResponse(w, http.StatusUnsupportedMediaType, api.Fail(api.CodeUnsupportedMediaType, "Content-Type must be application/json", nil)) + body, readOK := readRPCBody(w, r) + if !readOK { return } + ctx := rpcSecurityContext(r, userID, companyID) + response, statusCode := api.DispatchPath(ctx, modelName, methodName, body) + logRPCDispatch(ctx, modelName, methodName, response, statusCode, start) + api.WriteResponse(w, statusCode, response) +} + +// RPCJSONHandler is kept as an alias for tests that still call the prefix handler. +func RPCJSONHandler(w http.ResponseWriter, r *http.Request) { + RPCPrefixHandler(w, r) +} + +func readRPCBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) { + if !acceptsJSONContentType(r.Header.Get("Content-Type")) { + api.WriteResponse(w, http.StatusUnsupportedMediaType, api.Fail(api.CodeUnsupportedMediaType, "Content-Type must be application/json", nil)) + return nil, false + } requestBody, readOK := readBoundedRequestBody(r, maxRPCBodyBytes) if !readOK { api.WriteResponse(w, http.StatusBadRequest, api.Fail(api.CodeInvalidBody, "Could not read request body", nil)) - return + return nil, false } if int64(len(requestBody)) > maxRPCBodyBytes { api.WriteResponse(w, http.StatusRequestEntityTooLarge, api.Fail(api.CodePayloadTooLarge, "Request body too large", nil)) - return + return nil, false } + return requestBody, true +} - ctx := rpcSecurityContext(r, userID) - response, statusCode := api.Dispatch(ctx, requestBody) - logRPCDispatch(ctx, requestBody, response, statusCode, start) - api.WriteResponse(w, statusCode, response) +func handleRPCModelHealth(w http.ResponseWriter, r *http.Request, modelName string, companyID int64) { + userID := AuthenticatedUserID(r) + if userID <= 0 { + msg, _ := rpcUnauthorizedMessage(r) + api.WriteResponse(w, http.StatusUnauthorized, api.Fail(api.CodeUnauthorized, msg, nil)) + return + } + if ok, resp, status := ensureUserAllowedTenant(r, userID, companyID); !ok { + api.WriteResponse(w, status, resp) + return + } + _, registered := orm.Registry[modelName] + methods := make([]string, 0, len(api.PublicMethods)) + for name, allowed := range api.PublicMethods { + if allowed { + methods = append(methods, name) + } + } + if !registered { + api.WriteResponse(w, http.StatusNotFound, api.Fail(api.CodeModelNotFound, "unknown model", map[string]interface{}{"model": modelName})) + return + } + data := map[string]interface{}{ + "model": modelName, + "registered": true, + "rpc_methods": methods, + } + resp := api.SuccessWithMessage(data, "Model health OK") + api.WriteResponse(w, http.StatusOK, resp) } func acceptsJSONContentType(contentType string) bool { @@ -97,16 +212,18 @@ func readBoundedRequestBody(r *http.Request, maxBytes int64) ([]byte, bool) { return body, true } -func rpcSecurityContext(r *http.Request, userID int) context.Context { +func rpcSecurityContext(r *http.Request, userID int, companyID int64) context.Context { ctx := orm.ContextWithUID(r.Context(), userID) - if companyID := orm.CompanyIDFromContext(r.Context()); companyID > 0 { + if companyID > 0 { return orm.ContextWithCompanyID(ctx, companyID) } + if cid := orm.CompanyIDFromContext(r.Context()); cid > 0 { + return orm.ContextWithCompanyID(ctx, cid) + } return orm.ContextWithCompanyID(ctx, orm.ActiveCompanyIDForUser(ctx, userID)) } -func logRPCDispatch(ctx context.Context, requestBody []byte, response api.RPCResponse, statusCode int, start time.Time) { - modelName, methodName := parseRPCCallMeta(requestBody) +func logRPCDispatch(ctx context.Context, modelName, methodName string, response api.APIResponse, statusCode int, start time.Time) { event := applog.Event{ Component: "rpc", Operation: methodName, @@ -117,14 +234,24 @@ func logRPCDispatch(ctx context.Context, requestBody []byte, response api.RPCRes "status_code": statusCode, }, } - if !response.OK && response.Error != nil { + if !response.Success && response.Error != nil { event.Message = response.Error.Message + if event.Message == "" { + event.Message = response.Message + } if event.Message == "" { event.Message = "RPC call failed" } event.Status = "failure" - event.Code = response.Error.Code - event.Context["error"] = response.Error.Message + if response.Error.Context != nil { + if code, ok := response.Error.Context["code"].(string); ok { + event.Code = code + } + } + if event.Code == "" { + event.Code = response.Error.Code + } + event.Context["error"] = event.Message applog.ErrorCode(ctx, event.Code, event.Message, event) return } @@ -143,3 +270,4 @@ func parseRPCCallMeta(requestBody []byte) (modelName, methodName string) { } return meta.Model, meta.Method } + diff --git a/core/server/web/session_browser.go b/core/server/web/session_browser.go index 06d40675..7c707a0e 100644 --- a/core/server/web/session_browser.go +++ b/core/server/web/session_browser.go @@ -17,7 +17,7 @@ func sessionBrowserPresenceEnabled() bool { func sessionBrowserGrace() time.Duration { sec := config.AppConfig.SessionBrowserGraceSeconds - if sec <= 0 { + if 0 >= sec { sec = defaultSessionBrowserGraceSeconds } return time.Duration(sec) * time.Second diff --git a/core/server/web/settings_acl_common.go b/core/server/web/settings_acl_common.go index 6b28bdd3..2ce68732 100644 --- a/core/server/web/settings_acl_common.go +++ b/core/server/web/settings_acl_common.go @@ -35,7 +35,7 @@ func loadACLGroups(ctx context.Context, groupFilter string) ([]aclGroupCol, erro out := make([]aclGroupCol, 0, len(rows)) for _, row := range rows { id := intField(row["id"]) - if id <= 0 { + if 0 >= id { continue } label := stringField(row["name"]) diff --git a/core/server/web/settings_app_logs.go b/core/server/web/settings_app_logs.go index 13082021..3a98310e 100644 --- a/core/server/web/settings_app_logs.go +++ b/core/server/web/settings_app_logs.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "strconv" + "strings" "sumeru/core/engine/assets" "sumeru/core/engine/render" @@ -83,34 +84,26 @@ func resolveMenuID(ctx context.Context, menuXMLID string) (menuID int, menuIDStr } func loadAppLogEvents(ctx context.Context) ([]appLogEvent, error) { - logTable := orm.MustQuotedTableName(appLogModel) - query := ` - SELECT - to_char(create_date, 'YYYY-MM-DD HH24:MI:SS') AS created_at, - COALESCE(NULLIF(TRIM(module_name), ''), '') AS module, - COALESCE(NULLIF(TRIM(action), ''), '') AS action, - COALESCE(NULLIF(TRIM(detail), ''), '') AS detail - FROM ` + logTable + ` - ORDER BY create_date DESC, id DESC - LIMIT ` + strconv.Itoa(maxAppLogEvents) - - rows, err := orm.DB.QueryContext(ctx, query) + rows, err := orm.SearchPage(ctx, appLogModel, nil, maxAppLogEvents, 0, "create_date desc, id desc") if err != nil { return nil, err } - defer rows.Close() + events := make([]appLogEvent, 0, len(rows)) + for _, row := range rows { + events = append(events, appLogEvent{ + CreatedAt: formatAppLogTimestamp(row["create_date"]), + Module: strings.TrimSpace(orm.AsString(row["module_name"])), + Action: strings.TrimSpace(orm.AsString(row["action"])), + Detail: strings.TrimSpace(orm.AsString(row["detail"])), + }) + } + return events, nil +} - events := make([]appLogEvent, 0, maxAppLogEvents) - for rows.Next() { - var event appLogEvent - if err := rows.Scan(&event.CreatedAt, &event.Module, &event.Action, &event.Detail); err != nil { - WebLogEvent(ctx, WebLogInput{ - Route: appLogsRoute, Message: "App log row scan failed", - Operation: "load", Status: "partial", Err: err, - }) - continue - } - events = append(events, event) +func formatAppLogTimestamp(raw interface{}) string { + s := strings.TrimSpace(orm.AsString(raw)) + if len(s) >= 19 { + return s[:10] + " " + s[11:19] } - return events, rows.Err() + return s } diff --git a/core/server/web/settings_field_acl.go b/core/server/web/settings_field_acl.go index 36cb5038..a98d030d 100644 --- a/core/server/web/settings_field_acl.go +++ b/core/server/web/settings_field_acl.go @@ -25,10 +25,10 @@ type fieldACLMatrixCell struct { } type fieldACLMatrixRow struct { - FieldName string + FieldName string SchemaGroups bool - KernelTags []string - Cells []fieldACLMatrixCell + KernelTags []string + Cells []fieldACLMatrixCell } type settingsFieldACLData struct { @@ -199,7 +199,7 @@ func applyFieldACLMatrixPost(ctx context.Context, model string, form map[string] } func fieldACLMatrixRowName(model, field string, groupID int) string { - if groupID <= 0 { + if 0 >= groupID { return "matrix." + model + "." + field + ".global" } return fmt.Sprintf("matrix.%s.%s.g%d", model, field, groupID) @@ -382,12 +382,12 @@ func buildSettingsFieldACLPageData(ctx context.Context, menuIDStr string, page s crumbs := render.BuildSettingsHubBreadcrumbs(ctx) crumbs = append(crumbs, render.BreadcrumbItem{Label: "Field access matrix"}) pd := render.PageData{ - Title: "Field access matrix", - SettingsNavActive: true, - ActiveMenuID: menuIDStr, - BreadcrumbItems: crumbs, - ViewStylesheetURLs: []string{settingsHubStylesheetURL}, - ExtraBodyClasses: settingsHubBodyClass, + Title: "Field access matrix", + SettingsNavActive: true, + ActiveMenuID: menuIDStr, + BreadcrumbItems: crumbs, + ViewStylesheetURLs: []string{settingsHubStylesheetURL}, + ExtraBodyClasses: settingsHubBodyClass, } if page.Flash.Body != "" || page.Flash.Title != "" { pd.FlashMessages = []render.FlashMessage{page.Flash} diff --git a/core/server/web/settings_model_acl.go b/core/server/web/settings_model_acl.go index a468dce1..45f9ec67 100644 --- a/core/server/web/settings_model_acl.go +++ b/core/server/web/settings_model_acl.go @@ -114,7 +114,7 @@ func modelACLRedirectURL(model string, form map[string][]string) string { } func modelACLMatrixRowName(model string, groupID int) string { - if groupID <= 0 { + if 0 >= groupID { return "matrix." + model + ".global" } return fmt.Sprintf("matrix.%s.g%d", model, groupID) diff --git a/core/server/web/settings_platform.go b/core/server/web/settings_platform.go new file mode 100644 index 00000000..d3523f58 --- /dev/null +++ b/core/server/web/settings_platform.go @@ -0,0 +1,183 @@ +package web + +import ( + "context" + "net/http" + "strconv" + "strings" + + "sumeru/core/engine/render" + "sumeru/core/mail" + "sumeru/core/orm" + "sumeru/core/server/config" +) + +const ( + settingsAuthRoute = "/web/settings/auth" + settingsMailRoute = "/web/settings/mail" + authPasswordMinLengthKey = "auth.password_min_length" + authPasswordMinLengthDefault = "8" +) + +func registerSettingsPlatformRoutes() { + registerSession(http.MethodGet, settingsAuthRoute, SettingsAuthGetHandler) + registerSession(http.MethodPost, settingsAuthRoute, SettingsAuthPostHandler) + registerSession(http.MethodGet, settingsMailRoute, SettingsMailGetHandler) +} + +type settingsAuthData struct { + CSRFToken string + Flash render.FlashMessage + LocalLoginEnabled bool + PasswordMinLength int +} + +type settingsMailData struct { + CSRFToken string + Flash render.FlashMessage + Configured bool + SMTPHost string + SMTPPort int + SMTPFrom string + SMTPUser string +} + +func SettingsAuthGetHandler(w http.ResponseWriter, r *http.Request) { + if !requireLogin(w, r) { + return + } + if !requireSystemAdmin(w, r, true) { + return + } + ctx := r.Context() + menuIDStr, ok := resolveSettingsRootMenuID(w, r, ctx) + if !ok { + return + } + flash, _ := FlashFromQueryMessage(r.URL.Query().Get("msg")) + renderSettingsAuthPage(w, r, loadSettingsAuthData(ctx, CSRFTokenForRequest(r), flash), menuIDStr) +} + +func SettingsAuthPostHandler(w http.ResponseWriter, r *http.Request) { + if !requireLogin(w, r) { + return + } + if !requireSystemAdmin(w, r, true) { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + if !ValidateCSRF(r) { + http.Error(w, "invalid csrf", http.StatusForbidden) + return + } + ctx := r.Context() + localEnabled := strings.TrimSpace(r.PostForm.Get("local_login_enabled")) == "1" + minLen, err := strconv.Atoi(strings.TrimSpace(r.PostForm.Get("password_min_length"))) + if err != nil || minLen < 6 || minLen > 72 { + redirectWithWebMessage(w, r, settingsAuthRoute, "auth_settings_invalid") + return + } + localVal := "false" + if localEnabled { + localVal = "true" + } + if err := orm.SetConfigParam(ctx, authLocalConfigKey, localVal); err != nil { + redirectWithWebMessage(w, r, settingsAuthRoute, "auth_settings_failed") + return + } + if err := orm.SetConfigParam(ctx, authPasswordMinLengthKey, strconv.Itoa(minLen)); err != nil { + redirectWithWebMessage(w, r, settingsAuthRoute, "auth_settings_failed") + return + } + redirectWithWebMessage(w, r, settingsAuthRoute, "auth_settings_saved") +} + +func SettingsMailGetHandler(w http.ResponseWriter, r *http.Request) { + if !requireLogin(w, r) { + return + } + if !requireSystemAdmin(w, r, true) { + return + } + ctx := r.Context() + menuIDStr, ok := resolveSettingsRootMenuID(w, r, ctx) + if !ok { + return + } + flash, _ := FlashFromQueryMessage(r.URL.Query().Get("msg")) + renderSettingsMailPage(w, r, loadSettingsMailData(CSRFTokenForRequest(r), flash), menuIDStr) +} + +func loadSettingsAuthData(ctx context.Context, csrf string, flash render.FlashMessage) settingsAuthData { + localRaw := strings.ToLower(strings.TrimSpace(orm.GetConfig(ctx, authLocalConfigKey, "true"))) + minRaw := strings.TrimSpace(orm.GetConfig(ctx, authPasswordMinLengthKey, authPasswordMinLengthDefault)) + minLen, _ := strconv.Atoi(minRaw) + if minLen < 6 { + minLen = 8 + } + return settingsAuthData{ + CSRFToken: csrf, + Flash: flash, + LocalLoginEnabled: localRaw != "false" && localRaw != "0", + PasswordMinLength: minLen, + } +} + +func loadSettingsMailData(csrf string, flash render.FlashMessage) settingsMailData { + cfg := config.AppConfig + port := cfg.SMTPPort + if port <= 0 { + port = 587 + } + return settingsMailData{ + CSRFToken: csrf, + Flash: flash, + Configured: mail.Configured(), + SMTPHost: strings.TrimSpace(cfg.SMTPHost), + SMTPPort: port, + SMTPFrom: strings.TrimSpace(cfg.SMTPFrom), + SMTPUser: strings.TrimSpace(cfg.SMTPUser), + } +} + +func renderSettingsAuthPage(w http.ResponseWriter, r *http.Request, pageData settingsAuthData, menuIDStr string) { + ctx := r.Context() + renderShellPage(w, r, shellPageOpts{ + Route: settingsAuthRoute, + InnerTemplate: settingsAuthInnerTemplate, + InnerData: pageData, + MenuIDStr: menuIDStr, + Page: buildSettingsPlatformPageData(ctx, menuIDStr, "Authentication", pageData.Flash), + }) +} + +func renderSettingsMailPage(w http.ResponseWriter, r *http.Request, pageData settingsMailData, menuIDStr string) { + ctx := r.Context() + renderShellPage(w, r, shellPageOpts{ + Route: settingsMailRoute, + InnerTemplate: settingsMailInnerTemplate, + InnerData: pageData, + MenuIDStr: menuIDStr, + Page: buildSettingsPlatformPageData(ctx, menuIDStr, "Mail delivery", pageData.Flash), + }) +} + +func buildSettingsPlatformPageData(ctx context.Context, menuIDStr, sectionTitle string, flash render.FlashMessage) render.PageData { + crumbs := render.BuildSettingsHubBreadcrumbs(ctx) + crumbs = append(crumbs, render.BreadcrumbItem{Label: sectionTitle}) + pd := render.PageData{ + Title: sectionTitle, + SettingsNavActive: true, + ActiveMenuID: menuIDStr, + BreadcrumbItems: crumbs, + ViewStylesheetURLs: []string{settingsHubStylesheetURL}, + ExtraBodyClasses: settingsHubBodyClass, + } + if flash.Body != "" || flash.Title != "" { + pd.FlashMessages = []render.FlashMessage{flash} + } + return pd +} diff --git a/core/server/web/setup_handlers.go b/core/server/web/setup_handlers.go index ccb2c858..8d1556b9 100644 --- a/core/server/web/setup_handlers.go +++ b/core/server/web/setup_handlers.go @@ -2,6 +2,9 @@ package web import ( "context" + "crypto/hmac" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "html/template" @@ -29,6 +32,7 @@ type setupInitRequest struct { Email string `json:"email"` Password string `json:"password"` SetupToken string `json:"setup_token"` + CSRFToken string `json:"csrf_token"` } type setupPageData struct { @@ -37,6 +41,7 @@ type setupPageData struct { SetupTokenRequired bool AppName string Brand loginBrand + CSRFToken string } // SetupInitHandler runs database sync, installs base, bootstraps security from the JSON wizard payload, then restarts. @@ -60,12 +65,17 @@ func SetupInitHandler(w http.ResponseWriter, r *http.Request) { if !ok { return } + if !validateSetupCSRF(r, payload.CSRFToken) { + http.Error(w, "Invalid request", http.StatusForbidden) + return + } if !allowSetupRequest(w, r, payload.SetupToken) { return } if err := runFirstTimeSetup(ctx, toSetupAdminParams(payload)); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + logSetupFailure(ctx, "Setup init failed", err) + http.Error(w, "Setup failed", http.StatusInternalServerError) return } @@ -78,7 +88,38 @@ func SetupPageHandler(w http.ResponseWriter, r *http.Request) { if !requireSetupEnvironment(w, r) { return } - writeSetupPage(w, r.Context(), buildSetupPageData()) + pageData := buildSetupPageData() + pageData.CSRFToken = setSetupCSRFCookie(w) + writeSetupPage(w, r.Context(), pageData) +} + +func setSetupCSRFCookie(w http.ResponseWriter) string { + token := newSetupCSRFToken() + setNamedCookie(w, setupCSRFCookie, token, setupRoute, 600, http.SameSiteStrictMode) + return token +} + +func newSetupCSRFToken() string { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + panic("setup csrf: crypto/rand failed: " + err.Error()) + } + return hex.EncodeToString(buf) +} + +func validateSetupCSRF(r *http.Request, tokenFromBody string) bool { + cookie, err := r.Cookie(setupCSRFCookie) + if err != nil || cookie.Value == "" { + return false + } + got := strings.TrimSpace(tokenFromBody) + if got == "" { + got = strings.TrimSpace(r.Header.Get(csrfHeaderName)) + } + if got == "" { + return false + } + return hmac.Equal([]byte(got), []byte(cookie.Value)) } func parseSetupInitRequest(w http.ResponseWriter, requestBody []byte) (setupInitRequest, bool) { diff --git a/core/server/web/share_token.go b/core/server/web/share_token.go index 245bfb0e..bbeb5e74 100644 --- a/core/server/web/share_token.go +++ b/core/server/web/share_token.go @@ -29,7 +29,7 @@ func MintShareToken(issuerUID int, model string, resID int64) (string, error) { } func mintShareTokenAt(issuerUID int, model string, resID int64, exp int64) (string, error) { - if issuerUID <= 0 || resID <= 0 || strings.TrimSpace(model) == "" || exp <= 0 { + if 0 >= issuerUID || 0 >= resID || strings.TrimSpace(model) == "" || 0 >= exp { return "", fmt.Errorf("invalid share token input") } payload := fmt.Sprintf("%d|%s|%d|%d", issuerUID, model, resID, exp) @@ -72,15 +72,15 @@ func parseShareToken(token string) (parsedShareToken, error) { return out, fmt.Errorf("invalid token payload") } uid, err := strconv.Atoi(segments[0]) - if err != nil || uid <= 0 { + if err != nil || 0 >= uid { return out, fmt.Errorf("invalid issuer") } resID, err := strconv.ParseInt(segments[2], 10, 64) - if err != nil || resID <= 0 { + if err != nil || 0 >= resID { return out, fmt.Errorf("invalid record id") } exp, err := strconv.ParseInt(segments[3], 10, 64) - if err != nil || exp <= 0 { + if err != nil || 0 >= exp { return out, fmt.Errorf("invalid expiry") } if time.Now().UTC().Unix() > exp { diff --git a/core/server/web/shell_render.go b/core/server/web/shell_render.go index 782567a4..c83da308 100644 --- a/core/server/web/shell_render.go +++ b/core/server/web/shell_render.go @@ -95,7 +95,7 @@ func applyShellPageDefaults(page render.PageData, opts shellPageOpts, r *http.Re if !page.SuppressSidebar && !render.SidebarHasMenus(sidebarMenus) { page.SuppressSidebar = true } - if len(page.ViewStylesheetURLs) == 0 { + if 0 == len(page.ViewStylesheetURLs) { page.ViewStylesheetURLs = []string{workspaceStylesheetURL} } page.ExtraStylesheetURLs = resolveExtraStylesheets(page.ExtraStylesheetURLs, opts.ExtraStylesheetURLs) diff --git a/core/server/web/swc_activity_log.go b/core/server/web/swc_activity_log.go index 6f963041..c533c311 100644 --- a/core/server/web/swc_activity_log.go +++ b/core/server/web/swc_activity_log.go @@ -37,7 +37,7 @@ func SwcActivityLogHandler(w http.ResponseWriter, r *http.Request) { model := strings.TrimSpace(r.URL.Query().Get("model")) idRaw := strings.TrimSpace(r.URL.Query().Get("id")) recordID, _ := strconv.ParseInt(idRaw, 10, 64) - if model == "" || recordID <= 0 { + if model == "" || 0 >= recordID { http.Error(w, "model and id required", http.StatusBadRequest) return } diff --git a/core/server/web/swc_bus_channel.go b/core/server/web/swc_bus_channel.go index cc724079..af36cf16 100644 --- a/core/server/web/swc_bus_channel.go +++ b/core/server/web/swc_bus_channel.go @@ -13,7 +13,7 @@ const maxBusSubscriptions = 64 // AuthorizeSwcBusChannel checks whether uid may subscribe to channel. func AuthorizeSwcBusChannel(ctx context.Context, uid int, channel string) bool { - if uid <= 0 || channel == "" { + if 0 >= uid || channel == "" { return false } channel = strings.TrimSpace(channel) @@ -37,7 +37,7 @@ func AuthorizeSwcBusChannel(ctx context.Context, uid int, channel string) bool { } model := parts[1] rid, err := strconv.Atoi(parts[2]) - if err != nil || rid <= 0 { + if err != nil || 0 >= rid { return false } if err := orm.CheckModelAccess(ctx, uid, model, "read"); err != nil { @@ -55,7 +55,7 @@ func AuthorizeSwcBusChannel(ctx context.Context, uid int, channel string) bool { return false } cid, err := strconv.Atoi(parts[1]) - if err != nil || cid <= 0 { + if err != nil || 0 >= cid { return false } return orm.UserAllowedCompany(ctx, uid, int64(cid)) @@ -67,7 +67,7 @@ func AuthorizeSwcBusChannel(ctx context.Context, uid int, channel string) bool { // RecordBusChannel builds the standard record mutation channel name. func RecordBusChannel(model string, id int) string { model = strings.TrimSpace(model) - if model == "" || id <= 0 { + if model == "" || 0 >= id { return "" } return fmt.Sprintf("record/%s/%d", model, id) diff --git a/core/server/web/swc_bus_publish.go b/core/server/web/swc_bus_publish.go index 18bfa5c4..bbf16277 100644 --- a/core/server/web/swc_bus_publish.go +++ b/core/server/web/swc_bus_publish.go @@ -17,7 +17,7 @@ func PublishSwcBusEvent(ctx context.Context, channel string, payload map[string] if err != nil { return err } - if eventID <= 0 { + if 0 >= eventID { return nil } if err := orm.NotifyBusEvent(eventID); err != nil { @@ -29,7 +29,7 @@ func PublishSwcBusEvent(ctx context.Context, channel string, payload map[string] // PublishRecordBusEvent emits record.created|updated|deleted to record and model channels. func PublishRecordBusEvent(ctx context.Context, eventName, model string, id int) { - if model == "" || id <= 0 { + if model == "" || 0 >= id { return } payload := map[string]interface{}{ @@ -53,7 +53,7 @@ func dispatchLocalBusEvent(eventID int64, channel string, payload map[string]int } func dispatchBusEventByID(ctx context.Context, eventID int64) { - if eventID <= 0 { + if 0 >= eventID { return } channel, payload, err := orm.LoadBusEventByID(ctx, eventID) diff --git a/core/server/web/swc_direct.go b/core/server/web/swc_direct.go index 2d3c3647..903e1bc3 100644 --- a/core/server/web/swc_direct.go +++ b/core/server/web/swc_direct.go @@ -10,6 +10,7 @@ import ( "sumeru/addons/im" "sumeru/core/orm" + "sumeru/core/server/api" ) const ( @@ -38,7 +39,7 @@ func SwcDirectStatusHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() uid := AuthenticatedUserID(r) companyID := int(orm.CompanyIDFromContext(ctx)) - if companyID <= 0 { + if 0 >= companyID { companyID = int(orm.ActiveCompanyIDForUser(ctx, uid)) } unread := 0 @@ -109,14 +110,14 @@ func SwcDirectThreadHandler(w http.ResponseWriter, r *http.Request) { return } if !im.IMEnabled(r.Context()) { - http.Error(w, "messages disabled", http.StatusForbidden) + writeWebJSONError(w, http.StatusForbidden, api.CodeAccessDenied, "messages disabled", nil) return } peerID, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("userId"))) uid := AuthenticatedUserID(r) msgs, err := im.ListDirectThread(r.Context(), uid, peerID) if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writeWebJSONError(w, http.StatusBadRequest, api.CodeValidationError, err.Error(), nil) return } writeJSONResponse(w, map[string]interface{}{"messages": msgs}) @@ -127,7 +128,7 @@ func SwcDirectPostHandler(w http.ResponseWriter, r *http.Request) { return } if !im.IMEnabled(r.Context()) { - http.Error(w, "messages disabled", http.StatusForbidden) + writeWebJSONError(w, http.StatusForbidden, api.CodeAccessDenied, "messages disabled", nil) return } if !ParsePostForm(w, r) { @@ -140,7 +141,7 @@ func SwcDirectPostHandler(w http.ResponseWriter, r *http.Request) { uid := AuthenticatedUserID(r) id, err := im.PostDirectMessageLinked(r.Context(), uid, recipientID, body, resModel, resID) if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writeWebJSONError(w, http.StatusBadRequest, api.CodeValidationError, err.Error(), nil) return } writeJSONResponse(w, map[string]interface{}{"id": id}) @@ -155,7 +156,7 @@ func SwcDirectUploadHandler(w http.ResponseWriter, r *http.Request) { return } msgID, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("message_id"))) - if msgID <= 0 { + if 0 >= msgID { http.Error(w, "message_id required", http.StatusBadRequest) return } @@ -183,7 +184,7 @@ func SwcDirectUploadHandler(w http.ResponseWriter, r *http.Request) { } defer upload.Close() content, err := io.ReadAll(upload) - if err != nil || len(content) == 0 { + if err != nil || 0 == len(content) { http.Error(w, "empty file", http.StatusBadRequest) return } diff --git a/core/server/web/swc_import.go b/core/server/web/swc_import.go index 971d0dc0..ad1d777b 100644 --- a/core/server/web/swc_import.go +++ b/core/server/web/swc_import.go @@ -7,7 +7,6 @@ import ( "strings" "sumeru/core/orm" - "sumeru/core/report" ) func registerSwcImportRoute() { @@ -20,13 +19,12 @@ func SwcImportHandler(w http.ResponseWriter, r *http.Request) { return } batchID, _ := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("batch"))) - if batchID <= 0 { + if 0 >= batchID { http.Error(w, "batch required", http.StatusBadRequest) return } - batch, err := orm.SearchOne(r.Context(), report.BulkModelName, map[string]interface{}{"id": batchID}) - if err != nil || len(batch) == 0 { - http.NotFound(w, r) + batch, ok := loadOwnedBulkBatch(w, r, batchID) + if !ok { return } targetModel := orm.AsString(batch["target_model"]) @@ -49,14 +47,14 @@ func SwcImportHandler(w http.ResponseWriter, r *http.Request) { } } writeJSONResponse(w, map[string]interface{}{ - "batchId": batchID, - "targetModel": targetModel, - "importMode": orm.AsString(batch["import_mode"]), - "headers": headers, - "mapping": mapping, - "fields": fields, - "fieldLabels": fieldLabels, - "state": orm.AsString(batch["state"]), - "nextUrl": orm.AsString(batch["next_url"]), + "batchId": batchID, + "targetModel": targetModel, + "importMode": orm.AsString(batch["import_mode"]), + "headers": headers, + "mapping": mapping, + "fields": fields, + "fieldLabels": fieldLabels, + "state": orm.AsString(batch["state"]), + "nextUrl": orm.AsString(batch["next_url"]), }) } diff --git a/core/server/web/swc_notifications.go b/core/server/web/swc_notifications.go index 37562af1..38079a19 100644 --- a/core/server/web/swc_notifications.go +++ b/core/server/web/swc_notifications.go @@ -5,7 +5,7 @@ import ( ) const ( - swcNotificationsRoute = "/web/swc/notifications" + swcNotificationsRoute = "/web/swc/notifications" swcNotificationsReadRoute = "/web/swc/notifications/read" ) diff --git a/core/server/web/swc_payload.go b/core/server/web/swc_payload.go index 0a9f3b82..8320c19b 100644 --- a/core/server/web/swc_payload.go +++ b/core/server/web/swc_payload.go @@ -110,7 +110,7 @@ func serializeSwcBreadcrumbs(items []render.BreadcrumbItem) []swcmeta.Breadcrumb } func serializeListSections(sections []render.ListSection) []swcmeta.ListSectionMeta { - if len(sections) == 0 { + if 0 == len(sections) { return nil } out := make([]swcmeta.ListSectionMeta, 0, len(sections)) diff --git a/core/server/web/swc_saved_search.go b/core/server/web/swc_saved_search.go index 6fcf3755..f03e1f76 100644 --- a/core/server/web/swc_saved_search.go +++ b/core/server/web/swc_saved_search.go @@ -78,7 +78,7 @@ func SwcSavedSearchDeleteHandler(w http.ResponseWriter, r *http.Request) { return } id, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("id"))) - if err != nil || id <= 0 { + if err != nil || 0 >= id { http.Error(w, "invalid id", http.StatusBadRequest) return } diff --git a/core/server/web/testexports.go b/core/server/web/testexports.go index 5a57db3f..d8ae5d7e 100644 --- a/core/server/web/testexports.go +++ b/core/server/web/testexports.go @@ -5,24 +5,26 @@ import ( "database/sql" "fmt" "net/http" + "strings" "time" "sumeru/core/engine/render" "sumeru/core/engine/swcmeta" "sumeru/core/orm" "sumeru/core/report" + "sumeru/core/server/config" ) // Type aliases for external tests in core/server/web (web_test package). type ( - ShellPageOpts = shellPageOpts - SetupInitRequest = setupInitRequest - AppsBrowseState = appsBrowseState - ModuleRow = moduleRow - PinnedAppsRequest = pinnedAppsRequest - WorkspaceRequest = workspaceRequest - AppsModule = appsModule - AppsModuleGroup = appsModuleGroup + ShellPageOpts = shellPageOpts + SetupInitRequest = setupInitRequest + AppsBrowseState = appsBrowseState + ModuleRow = moduleRow + PinnedAppsRequest = pinnedAppsRequest + WorkspaceRequest = workspaceRequest + AppsModule = appsModule + AppsModuleGroup = appsModuleGroup ) // Route and query constants for external tests. @@ -40,6 +42,7 @@ const ( TestCompanySwitchRoute = companySwitchRoute TestModuleActionRoute = moduleActionRoute TestAPIRPCRoute = apiRPCRoute + TestAPIRPCRoutePrefix = apiRPCRoutePrefix TestWorkspaceActionParam = workspaceActionParam TestWorkspaceMenuIDParam = workspaceMenuIDParam TestWorkspaceViewTypeParam = workspaceViewTypeParam @@ -274,6 +277,11 @@ func ImportCSVFlashMessage(createdCount int) string { return importCSVFlashMessa func ParseCompanySwitchForm(r *http.Request) companySwitchForm { return parseCompanySwitchForm(r) } +// SwitchActiveCompanyForTest exercises active company switching without HTTP. +func SwitchActiveCompanyForTest(ctx context.Context, userID, companyID int) { + switchActiveCompany(ctx, userID, companyID) +} + // LoginURLWithReturn returns the login path (return target is stored in a cookie by redirectToLogin). func LoginURLWithReturn(_ string) string { return loginRoute } @@ -401,7 +409,9 @@ func ParseContentPathForTest(path string) (field string, id int, ok bool) { func ContentInlineAllowedForTest(mime string) bool { return contentInlineAllowed(mime) } -func SafeContentDispositionInlineForTest(name string) string { return safeContentDispositionInline(name) } +func SafeContentDispositionInlineForTest(name string) string { + return safeContentDispositionInline(name) +} func BulkPreviewHandlerForTest(w http.ResponseWriter, r *http.Request) { BulkPreviewHandler(w, r) @@ -419,6 +429,11 @@ func BulkStatusHandlerForTest(w http.ResponseWriter, r *http.Request) { BulkStatusHandler(w, r) } +// BulkBatchOwnedByForTest exposes bulk batch ownership checks for tests. +func BulkBatchOwnedByForTest(batch map[string]interface{}, uid int) bool { + return bulkBatchOwnedBy(batch, uid) +} + func SwcImportHandlerForTest(w http.ResponseWriter, r *http.Request) { SwcImportHandler(w, r) } @@ -470,6 +485,27 @@ func AuthViaSessionForTest(r *http.Request) bool { return AuthViaSession(r) } // RPCJSONHandlerForTest exposes the JSON RPC handler for external tests. func RPCJSONHandlerForTest(w http.ResponseWriter, r *http.Request) { RPCJSONHandler(w, r) } +// ApplyDefaultRPCHeadersForTest sets required RPC headers for handler tests. +func ApplyDefaultRPCHeadersForTest(r *http.Request) { + if strings.TrimSpace(config.AppConfig.DbName) == "" { + config.AppConfig.DbName = "test_db" + } + r.Header.Set(sumeruDatabaseHeader, config.AppConfig.DbName) + if strings.TrimSpace(r.Header.Get(sumeruTenantHeader)) == "" { + r.Header.Set(sumeruTenantHeader, "1") + } +} + +// TestRPCURLForTest builds a path-based RPC URL for tests. +func TestRPCURLForTest(model, method string) string { + return apiRPCRoutePrefix + model + "/" + method +} + +// TestAuthLoginURLForTest is POST /api/rpc/auth.login. +func TestAuthLoginURLForTest() string { + return apiRPCRoutePrefix + rpcAuthLoginSegment +} + // BuildSessionCookieForTest exposes session cookie construction for tests. func BuildSessionCookieForTest(value string, deleteCookie bool) *http.Cookie { return buildSessionCookie(value, deleteCookie) @@ -490,10 +526,28 @@ func OAuthCallbackForTest(w http.ResponseWriter, r *http.Request) { OAuthCallbac func OAuthStartForTest(w http.ResponseWriter, r *http.Request) { OAuthStartHandler(w, r) } // LinkOAuthUserForTest exposes OAuth account linking and JIT provisioning for tests. -func LinkOAuthUserForTest(ctx context.Context, providerID int, subject, email, policy string) (int, error) { - return linkOAuthUser(ctx, providerID, subject, email, policy) +func LinkOAuthUserForTest(ctx context.Context, providerID int, subject, email string, emailVerified bool, policy string) (int, error) { + return linkOAuthUser(ctx, providerID, subject, email, emailVerified, policy) +} + +// ResolveOAuthIdentityForTest exposes OIDC identity resolution for tests. +func ResolveOAuthIdentityForTest(ctx context.Context, prov map[string]interface{}, tok map[string]interface{}) (email, sub string, emailVerified bool, err error) { + return resolveOAuthIdentity(ctx, prov, tok) +} + +// PublicBaseURLForTest exposes canonical public origin resolution for tests. +func PublicBaseURLForTest(r *http.Request) (string, error) { + return publicBaseURL(r) } +// ValidateSetupCSRFForTest exposes setup wizard CSRF validation for tests. +func ValidateSetupCSRFForTest(r *http.Request, tokenFromBody string) bool { + return validateSetupCSRF(r, tokenFromBody) +} + +// TestSetupCSRFCookie is the HttpOnly setup CSRF cookie name. +const TestSetupCSRFCookie = setupCSRFCookie + // AuthLocalEnabledForTest reports whether password login is shown on the login page. func AuthLocalEnabledForTest(ctx context.Context) bool { return authLocalEnabled(ctx) } @@ -609,6 +663,11 @@ func RequireSystemAdminForTest(w http.ResponseWriter, r *http.Request, redirectO return requireSystemAdmin(w, r, redirectOnDeny) } +// RequireModuleManagerOrSystemForTest exposes requireModuleManagerOrSystem for handler tests. +func RequireModuleManagerOrSystemForTest(w http.ResponseWriter, r *http.Request, redirectOnDeny bool) bool { + return requireModuleManagerOrSystem(w, r, redirectOnDeny) +} + // RequireModelAccessForTest exposes requireModelAccess for handler tests. func RequireModelAccessForTest(w http.ResponseWriter, r *http.Request, model, perm string) bool { return requireModelAccess(w, r, model, perm) diff --git a/core/server/web/user_security_handler.go b/core/server/web/user_security_handler.go index d8e76e61..8fbb3e46 100644 --- a/core/server/web/user_security_handler.go +++ b/core/server/web/user_security_handler.go @@ -25,7 +25,7 @@ func UserSecurityPostHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() actor := orm.SecurityUID(ctx) userID, _ := strconv.Atoi(r.PostForm.Get("user_id")) - if userID <= 0 { + if 0 >= userID { http.Error(w, "user_id required", http.StatusBadRequest) return } diff --git a/core/server/web/web_api_json.go b/core/server/web/web_api_json.go new file mode 100644 index 00000000..d1f57d4f --- /dev/null +++ b/core/server/web/web_api_json.go @@ -0,0 +1,11 @@ +package web + +import ( + "net/http" + + "sumeru/core/server/api" +) + +func writeWebJSONError(w http.ResponseWriter, status int, code, message string, details map[string]interface{}) { + api.WriteResponse(w, status, api.Fail(code, message, details)) +} diff --git a/core/server/web/web_constants.go b/core/server/web/web_constants.go index aeadffea..59eb3f98 100644 --- a/core/server/web/web_constants.go +++ b/core/server/web/web_constants.go @@ -24,6 +24,7 @@ const ( apiHealthRoute = "/api/health" apiReadyRoute = "/api/ready" apiRPCRoute = "/api/rpc" + apiRPCRoutePrefix = "/api/rpc/" metricsRoute = "/metrics" setupRoute = "/setup" setupInitRoute = "/setup/init" @@ -65,6 +66,7 @@ const ( totpLoginTemplateFile = render.TemplateAuthTOTP totpLoginRoute = "/web/login/totp" loginCSRFCookie = "sumeru_login_csrf" + setupCSRFCookie = "sumeru_setup_csrf" loginNextCookie = "sumeru_login_next" pendingMFACookie = "sumeru_pending_mfa" trustedDeviceCookie = "sumeru_trusted_device" @@ -97,6 +99,7 @@ const ( // ACL group XML ids. const groupSystemXML = "base.group_system" +const groupModuleManagerXML = "base.group_module_manager" // Common HTTP response messages. const ( @@ -155,6 +158,8 @@ const ( settingsHubStylesheetURL = "/static/css/sumeru-settings-hub.css" settingsHubBodyClass = " sum-body--settings-hub" settingsAccountInnerTemplate = render.TemplatePagesSettingsAccount + settingsAuthInnerTemplate = render.TemplatePagesSettingsAuth + settingsMailInnerTemplate = render.TemplatePagesSettingsMail settingsFieldACLInnerTemplate = render.TemplatePagesSettingsFieldACL settingsModelACLInnerTemplate = render.TemplatePagesSettingsModelACL groupUserXML = "base.group_user" diff --git a/core/server/web/workspace.go b/core/server/web/workspace.go index 54d539c7..9cc40143 100644 --- a/core/server/web/workspace.go +++ b/core/server/web/workspace.go @@ -111,7 +111,7 @@ func redirectIfNoWindowAction(w http.ResponseWriter, r *http.Request, actionQuer return false } - if actionID == 0 && redirectURL == appsRoute { + if 0 == actionID && redirectURL == appsRoute { WebLogf(r.Context(), workspaceRoute, "no action for query action=%q menu_id=%q; redirecting to apps", actionQuery, menuQuery) } http.Redirect(w, r, redirectURL, http.StatusFound) diff --git a/core/server/web/workspace_iframe.go b/core/server/web/workspace_iframe.go index 84168bda..776b45bd 100644 --- a/core/server/web/workspace_iframe.go +++ b/core/server/web/workspace_iframe.go @@ -40,7 +40,7 @@ func buildIframeSwcPayload(ctx context.Context, actionID int, menuID, iframeURL } func urlActionTitle(ctx context.Context, actionID int) string { - if actionID <= 0 { + if 0 >= actionID { return "Report" } row, err := orm.SearchOne(ctx, sysActionURLModel, map[string]interface{}{"id": actionID}) diff --git a/core/server/web/workspace_load.go b/core/server/web/workspace_load.go index d472727d..349e7362 100644 --- a/core/server/web/workspace_load.go +++ b/core/server/web/workspace_load.go @@ -86,4 +86,3 @@ func parsePositiveRecordID(recordIDRaw string) (int, bool) { recordID, err := strconv.Atoi(strings.TrimSpace(recordIDRaw)) return recordID, err == nil && recordID > 0 } - diff --git a/core/server/web/workspace_request.go b/core/server/web/workspace_request.go index aeca77c8..2363ecc3 100644 --- a/core/server/web/workspace_request.go +++ b/core/server/web/workspace_request.go @@ -134,7 +134,7 @@ func resolveWorkspaceByModel(ctx context.Context, r *http.Request, model string) func workspaceViewModeCandidates(r *http.Request, actionData map[string]interface{}) []string { modes := splitViewModes(strings.TrimSpace(orm.AsString(actionData["view_mode"]))) - if len(modes) == 0 { + if 0 == len(modes) { modes = []string{workspaceViewModeList} } @@ -204,11 +204,11 @@ func workspaceViewNotFoundError(targetModel string, modes []string, lastErr erro } func actionViewModesForTabs(actionData map[string]interface{}) []string { - if len(actionData) == 0 { + if 0 == len(actionData) { return nil } modes := splitViewModes(strings.TrimSpace(orm.AsString(actionData["view_mode"]))) - if len(modes) == 0 { + if 0 == len(modes) { return []string{workspaceViewModeList} } out := make([]string, 0, len(modes)) diff --git a/core/server/web/workspace_resolve.go b/core/server/web/workspace_resolve.go index 53f92a4a..430242d8 100644 --- a/core/server/web/workspace_resolve.go +++ b/core/server/web/workspace_resolve.go @@ -2,7 +2,6 @@ package web import ( "context" - "database/sql" "strconv" "strings" @@ -53,7 +52,7 @@ func windowActionIDFromMenu(ctx context.Context, menuID int) int { func menuRecordActionID(menuRecord map[string]interface{}) (actionID int, ok bool) { actionID64, hasAction := orm.CoerceInt64(menuRecord["action_id"]) - if !hasAction || actionID64 == 0 { + if !hasAction || 0 == actionID64 { return 0, false } return int(actionID64), true @@ -112,7 +111,7 @@ func menuIsUnderXMLRoot(ctx context.Context, menuQuery, rootMenuXMLID string) bo func resolvedMenuIDFromXML(ctx context.Context, menuXMLID string) (menuID int, ok bool) { menuID, _, err := orm.ResolveXmlId(ctx, menuXMLID) - if err != nil || menuID <= 0 { + if err != nil || 0 >= menuID { return 0, false } return menuID, true @@ -120,7 +119,7 @@ func resolvedMenuIDFromXML(ctx context.Context, menuXMLID string) (menuID int, o func parseMenuIDString(menuQuery string) (menuID int, ok bool) { menuID, err := strconv.Atoi(strings.TrimSpace(menuQuery)) - if err != nil || menuID <= 0 { + if err != nil || 0 >= menuID { return 0, false } return menuID, true @@ -134,7 +133,7 @@ func actionWindowTargetModel(actionData map[string]interface{}) string { // CanonicalMenuID rewrites folder/root menu_id to the leaf menu that owns actionID. func CanonicalMenuID(ctx context.Context, menuQuery string, actionID int) string { menuID, ok := parseMenuIDString(menuQuery) - if !ok || actionID == 0 { + if !ok || 0 == actionID { return menuQuery } menuRecord, err := orm.SearchOne(ctx, sysMenuModel, map[string]interface{}{"id": menuID}) @@ -151,7 +150,7 @@ func CanonicalMenuID(ctx context.Context, menuQuery string, actionID int) string } func menuIDForWindowAction(ctx context.Context, parentMenuID, actionID int) int { - if parentMenuID <= 0 || actionID == 0 { + if 0 >= parentMenuID || 0 == actionID { return 0 } return walkMenuChildren(ctx, parentMenuID, func(childMenuID int, childActionID int) (int, bool) { @@ -167,27 +166,17 @@ func menuIDForWindowAction(ctx context.Context, parentMenuID, actionID int) int // walkMenuChildren DFS-walks sys.menu children (sequence, id) and returns the first match from visit. func walkMenuChildren(ctx context.Context, parentMenuID int, visit func(childMenuID, childActionID int) (int, bool)) int { - menuTable := orm.MustQuotedTableName(sysMenuModel) - rows, err := orm.DB.QueryContext(ctx, - `SELECT id, action_id FROM `+menuTable+` WHERE parent_id = $1 ORDER BY sequence ASC, id ASC`, - parentMenuID, - ) + rows, err := orm.SearchPage(ctx, sysMenuModel, [][]interface{}{{"parent_id", "=", parentMenuID}}, 500, 0, "sequence asc, id asc") if err != nil { return 0 } - defer rows.Close() - - for rows.Next() { - var childMenuID int - var childActionID sql.NullInt64 - if err := rows.Scan(&childMenuID, &childActionID); err != nil { + for _, row := range rows { + childMenuID, _ := orm.CoerceInt64(row["id"]) + if childMenuID <= 0 { continue } - aid := 0 - if childActionID.Valid { - aid = int(childActionID.Int64) - } - if result, ok := visit(childMenuID, aid); ok { + aid, _ := orm.CoerceInt64(row["action_id"]) + if result, ok := visit(int(childMenuID), int(aid)); ok { return result } } diff --git a/core/swc/src/import/ImportWizard.ts b/core/swc/src/import/ImportWizard.ts index 1fbcea2b..79563885 100644 --- a/core/swc/src/import/ImportWizard.ts +++ b/core/swc/src/import/ImportWizard.ts @@ -248,8 +248,18 @@ async function postJSON(url: string, payload: Record, csrf: str }, body: JSON.stringify({ ...payload, csrf_token: csrf }), }); + const text = await res.text(); if (!res.ok) { - throw new Error(await res.text()); + let message = text; + try { + const errBody = JSON.parse(text) as { error?: { message?: string } }; + if (errBody.error?.message) { + message = errBody.error.message; + } + } catch { + // plain-text error body + } + throw new Error(message || `HTTP ${res.status}`); } - return res.json(); + return JSON.parse(text) as unknown; } diff --git a/core/swc/src/main.ts b/core/swc/src/main.ts index 2a0fe912..44badea0 100644 --- a/core/swc/src/main.ts +++ b/core/swc/src/main.ts @@ -92,7 +92,13 @@ function buildEnv(boot: ReturnType): SwcEnv { const router = new RouterService(); const bus = new BusService(); const command = new CommandService(); - const rpc = new RpcService(boot.rpcUrl, boot.csrfToken); + const tenant = String(boot.activeCompanyId || boot.company?.id || ""); + const rpc = new RpcService( + boot.rpcUrl, + boot.csrfToken, + boot.apiDatabase ?? "", + tenant, + ); const services = { rpc, http: new HttpService(boot.csrfToken), diff --git a/core/swc/src/services/rpc.ts b/core/swc/src/services/rpc.ts index e16e52b0..c6e59549 100644 --- a/core/swc/src/services/rpc.ts +++ b/core/swc/src/services/rpc.ts @@ -6,23 +6,39 @@ import { } from "./session-redirect.js"; interface RpcEnvelope { - ok?: boolean; - result?: T; - error?: { code?: string; message: string; details?: unknown }; + success?: boolean; + data?: T; + error?: { code?: string; message?: string; context?: { code?: string } }; } function isRpcEnvelope(data: unknown): data is RpcEnvelope { return typeof data === "object" && data !== null; } +function rpcErrorCode(error: RpcEnvelope["error"]): string | undefined { + if (!error) return undefined; + if (error.code) return error.code; + const ctx = error.context; + if (ctx && typeof ctx.code === "string") return ctx.code; + return undefined; +} + export class RpcService { - private readonly url: string; + private readonly rpcBase: string; private readonly csrfToken: string; + private readonly apiDatabase: string; + private readonly apiTenant: string; private readonly searchReadCache = new Map[]>>(); - constructor(url: string, csrfToken: string) { - this.url = url; + constructor(rpcBase: string, csrfToken: string, apiDatabase: string, apiTenant: string) { + this.rpcBase = rpcBase.replace(/\/$/, ""); this.csrfToken = csrfToken; + this.apiDatabase = apiDatabase; + this.apiTenant = apiTenant; + } + + private rpcURL(model: string, method: string): string { + return `${this.rpcBase}/${model}/${method}`; } private searchReadKey( @@ -39,16 +55,14 @@ export class RpcService { this.searchReadCache.clear(); } - private async dispatch(model: string, method: string, args: unknown[] = [], kwargs: Record = {}): Promise { - const body: Record = { model, method, args }; - if (Object.keys(kwargs).length > 0) { - body.kwargs = kwargs; - } - const res = await fetch(this.url, { + private async dispatch(model: string, method: string, body: Record): Promise { + const res = await fetch(this.rpcURL(model, method), { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": this.csrfToken, + "X-Sumeru-Database": this.apiDatabase, + "X-Sumeru-Tenant": this.apiTenant, }, credentials: "same-origin", body: JSON.stringify(body), @@ -64,20 +78,23 @@ export class RpcService { throw new SwcError("RPC response is not an object", "rpc_error"); } const data = raw as RpcEnvelope; - if (data.ok === false || data.error) { - if (shouldRedirectForRpcError(data.error?.code)) { + if (data.success === false || data.error) { + const code = rpcErrorCode(data.error); + if (shouldRedirectForRpcError(code)) { redirectToLoginIfNeeded(); } throw new SwcError(data.error?.message ?? "RPC failed", "rpc_error", data.error); } - return data.result as T; + return data.data as T; } searchRead(model: string, domain: unknown[] = [], fields: string[] = [], limit = 80): Promise[]> { const key = this.searchReadKey(model, domain, fields, limit); let pending = this.searchReadCache.get(key); if (!pending) { - pending = this.dispatch[]>(model, "search_read", [domain, fields], { + pending = this.dispatch[]>(model, "search_read", { + domain, + fields, limit, }); this.searchReadCache.set(key, pending); @@ -89,22 +106,26 @@ export class RpcService { } read(model: string, ids: number[], fields: string[] = []): Promise[]> { - return this.dispatch(model, "read", [ids, fields]); + const body: Record = { ids }; + if (fields.length > 0) { + body.fields = fields; + } + return this.dispatch(model, "read", body); } write(model: string, ids: number[], values: Record): Promise { this.invalidateSearchReadCache(); - return this.dispatch(model, "write", [ids, values]); + return this.dispatch(model, "write", { ids, values }); } create(model: string, values: Record): Promise { this.invalidateSearchReadCache(); - return this.dispatch(model, "create", [values]); + return this.dispatch(model, "create", { values }); } unlink(model: string, ids: number[]): Promise { this.invalidateSearchReadCache(); - return this.dispatch(model, "unlink", [ids]); + return this.dispatch(model, "unlink", { ids }); } callMethod( @@ -113,8 +134,11 @@ export class RpcService { recordId: number, vals?: Record, ): Promise { - const args: unknown[] = vals ? [recordId, method, vals] : [recordId, method]; - return this.dispatch(model, "call", args); + const body: Record = { id: recordId, method }; + if (vals) { + body.vals = vals; + } + return this.dispatch(model, "call", body); } readGroup( @@ -129,7 +153,7 @@ export class RpcService { groupby: groupBy, fields: fields.map((name) => ({ name, field: name, measure: name === "id" ? "count" : "sum" })), }; - return this.dispatch(model, "read_group", [spec], { limit }); + return this.dispatch(model, "read_group", { spec, limit }); } onchange( @@ -137,6 +161,6 @@ export class RpcService { values: Record, field: string, ): Promise> { - return this.dispatch(model, "onchange", [values, field]); + return this.dispatch(model, "onchange", { values, field }); } } diff --git a/core/swc/src/types/bootstrap.ts b/core/swc/src/types/bootstrap.ts index 8ed801af..2200e724 100644 --- a/core/swc/src/types/bootstrap.ts +++ b/core/swc/src/types/bootstrap.ts @@ -41,6 +41,7 @@ export interface SwcBootstrapApp { export interface SwcBootstrap { csrfToken: string; rpcUrl: string; + apiDatabase: string; swcApiBase: string; user: SwcBootstrapUser; company: SwcBootstrapCompany; diff --git a/core/swc/src/views/shared/view-toolbar.ts b/core/swc/src/views/shared/view-toolbar.ts index 843e608c..89b3072d 100644 --- a/core/swc/src/views/shared/view-toolbar.ts +++ b/core/swc/src/views/shared/view-toolbar.ts @@ -27,13 +27,64 @@ function bulkJobFormUrl(jobId: number): string { return `/web?${params.toString()}`; } +function parseDownloadFilename(contentDisposition: string | null): string { + if (!contentDisposition) return "export"; + const star = /filename\*=UTF-8''([^;\s]+)/i.exec(contentDisposition); + if (star?.[1]) { + try { + return decodeURIComponent(star[1]); + } catch { + return star[1]; + } + } + const quoted = /filename="([^"]+)"/i.exec(contentDisposition); + if (quoted?.[1]) return quoted[1]; + const plain = /filename=([^;\s]+)/i.exec(contentDisposition); + if (plain?.[1]) return plain[1].replace(/^"|"$/g, ""); + return "export"; +} + +async function downloadProtectedGET(url: string, csrfToken: string, failureLabel: string): Promise { + const headers: Record = {}; + if (csrfToken) headers["X-CSRF-Token"] = csrfToken; + const res = await fetch(url, { method: "GET", credentials: "same-origin", headers }); + if (!res.ok) { + window.alert(`${failureLabel} failed: ${await res.text()}`); + return; + } + const blob = await res.blob(); + const filename = parseDownloadFilename(res.headers.get("Content-Disposition")); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = filename; + anchor.rel = "noopener"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(objectUrl); +} + +function protectedDownloadButton( + url: string, + label: string, + csrfToken: string, + className = "sum-popover-menu-link", +): HTMLElement { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = className; + btn.textContent = label; + btn.addEventListener("click", () => void downloadProtectedGET(url, csrfToken, label)); + return btn; +} + async function queueAsyncExport( payload: SwcWorkspacePayload, fields: string, format: "csv" | "xlsx", ): Promise { const query = exportQuery(payload, fields); - query.delete("csrf_token"); const body = new URLSearchParams(); body.set("csrf_token", payload.csrfToken); body.set("model", payload.model); @@ -113,7 +164,6 @@ export function exportQuery( if (payload.listFilter) params.set("filter", payload.listFilter); if (payload.listDomain) params.set("domain", payload.listDomain); if (payload.listSearch) params.set("q", payload.listSearch); - if (payload.csrfToken) params.set("csrf_token", payload.csrfToken); return params; } @@ -316,7 +366,12 @@ export function buildReportActionEntries( : listTotal > 0 && !largeList && (fmt === "csv" || fmt === "xlsx") ? label : label; - const node = linkButton(`${route}?${exportParams.toString()}`, syncLabel, "sum-popover-menu-link"); + const node = protectedDownloadButton( + `${route}?${exportParams.toString()}`, + syncLabel, + payload.csrfToken, + "sum-popover-menu-link", + ); if (largeList && fmt === "pdf") { node.title = `PDF export includes at most ${MAX_SYNC_EXPORT_ROWS} rows. Use background CSV export for full data.`; } @@ -384,9 +439,10 @@ function appendReadGroupExportEntries( if (groups.length > 0 && measures.length > 0) { entries.push({ label: "Export pivot CSV", - node: linkButton( + node: protectedDownloadButton( pivotExportUrl(payload, groups, measures), "Export pivot CSV", + payload.csrfToken, "sum-popover-menu-link", ), }); @@ -398,9 +454,10 @@ function appendReadGroupExportEntries( if (groupField && measureField) { entries.push({ label: "Export graph CSV", - node: linkButton( + node: protectedDownloadButton( graphExportUrl(payload, groupField, measureField), "Export graph CSV", + payload.csrfToken, "sum-popover-menu-link", ), }); diff --git a/core/swc/tests/runtime/rpc-cache.test.ts b/core/swc/tests/runtime/rpc-cache.test.ts index 98b034bd..c5e204fd 100644 --- a/core/swc/tests/runtime/rpc-cache.test.ts +++ b/core/swc/tests/runtime/rpc-cache.test.ts @@ -5,11 +5,11 @@ describe("RpcService searchRead cache", () => { it("dedupes identical search_read calls", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ ok: true, result: [{ id: 1, name: "India" }] }), + json: async () => ({ success: true, data: [{ id: 1, name: "India" }] }), }); vi.stubGlobal("fetch", fetchMock); - const rpc = new RpcService("/web/rpc", "tok"); + const rpc = new RpcService("/api/rpc", "tok", "test_db", "1"); const a = rpc.searchRead("core.country", [], ["id", "name"], 200); const b = rpc.searchRead("core.country", [], ["id", "name"], 200); expect(a).toBe(b); @@ -24,16 +24,16 @@ describe("RpcService searchRead cache", () => { .fn() .mockResolvedValueOnce({ ok: true, - json: async () => ({ ok: true, result: [{ id: 1 }] }), + json: async () => ({ success: true, data: [{ id: 1 }] }), }) - .mockResolvedValueOnce({ ok: true, json: async () => ({ ok: true, result: true }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ success: true, data: true }) }) .mockResolvedValueOnce({ ok: true, - json: async () => ({ ok: true, result: [{ id: 1 }] }), + json: async () => ({ success: true, data: [{ id: 1 }] }), }); vi.stubGlobal("fetch", fetchMock); - const rpc = new RpcService("/web/rpc", "tok"); + const rpc = new RpcService("/api/rpc", "tok", "test_db", "1"); await rpc.searchRead("core.country", [], ["id", "name"], 200); await rpc.write("core.partner", [1], { name: "Acme" }); await rpc.searchRead("core.country", [], ["id", "name"], 200); @@ -42,17 +42,17 @@ describe("RpcService searchRead cache", () => { vi.unstubAllGlobals(); }); - it("passes optional vals as call args[2]", async () => { + it("passes optional vals on call", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ ok: true, result: { close: true } }), + json: async () => ({ success: true, data: { close: true } }), }); vi.stubGlobal("fetch", fetchMock); - const rpc = new RpcService("/web/rpc", "tok"); + const rpc = new RpcService("/api/rpc", "tok", "test_db", "1"); await rpc.callMethod("crm.lead", "action_merge_wizard", 3, { active_ids: "3,7" }); const body = JSON.parse(fetchMock.mock.calls[0][1].body as string); - expect(body.args).toEqual([3, "action_merge_wizard", { active_ids: "3,7" }]); + expect(body).toEqual({ id: 3, method: "action_merge_wizard", vals: { active_ids: "3,7" } }); vi.unstubAllGlobals(); }); diff --git a/core/swc/tests/runtime/scheduler.test.ts b/core/swc/tests/runtime/scheduler.test.ts new file mode 100644 index 00000000..b37ffb3e --- /dev/null +++ b/core/swc/tests/runtime/scheduler.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { flushScheduledRenders, scheduleRender } from "../../src/runtime/scheduler.js"; + +describe("scheduler coalesced renders", () => { + beforeEach(() => { + vi.stubGlobal( + "requestAnimationFrame", + (cb: FrameRequestCallback) => setTimeout(() => cb(0), 0) as unknown as number, + ); + vi.stubGlobal("cancelAnimationFrame", (id: number) => clearTimeout(id)); + }); + + afterEach(() => { + flushScheduledRenders(); + vi.unstubAllGlobals(); + }); + + it("batches multiple scheduleRender calls into one flush", async () => { + let patches = 0; + const rootElement = document.createElement("div"); + document.body.append(rootElement); + const component = { + rootElement, + patch() { + patches += 1; + }, + }; + scheduleRender(component); + scheduleRender(component); + expect(patches).toBe(0); + flushScheduledRenders(); + expect(patches).toBe(1); + }); +}); diff --git a/core/swc/tests/services/rpc.test.ts b/core/swc/tests/services/rpc.test.ts index ffe1f81a..c4d82fb7 100644 --- a/core/swc/tests/services/rpc.test.ts +++ b/core/swc/tests/services/rpc.test.ts @@ -3,6 +3,10 @@ import { RpcService } from "../../src/services/rpc.js"; import { SwcError } from "../../src/runtime/error.js"; import { resetSessionRedirectForTests } from "../../src/services/session-redirect.js"; +function testRpc(): RpcService { + return new RpcService("/api/rpc", "tok", "test_db", "1"); +} + describe("RpcService", () => { beforeEach(() => { resetSessionRedirectForTests(); @@ -16,22 +20,24 @@ describe("RpcService", () => { it("searchRead caches identical requests", async () => { vi.mocked(fetch).mockResolvedValue({ ok: true, - json: async () => ({ ok: true, result: [{ id: 1 }] }), + json: async () => ({ success: true, data: [{ id: 1 }] }), } as Response); - const rpc = new RpcService("/web/swc/rpc", "tok"); + const rpc = testRpc(); const a = rpc.searchRead("m", [], ["name"], 10); const b = rpc.searchRead("m", [], ["name"], 10); expect(a).toBe(b); await a; expect(fetch).toHaveBeenCalledTimes(1); + const [url] = vi.mocked(fetch).mock.calls[0] as [string]; + expect(url).toBe("/api/rpc/m/search_read"); }); it("write invalidates searchRead cache", async () => { vi.mocked(fetch).mockResolvedValue({ ok: true, - json: async () => ({ ok: true, result: true }), + json: async () => ({ success: true, data: true }), } as Response); - const rpc = new RpcService("/web/swc/rpc", "tok"); + const rpc = testRpc(); await rpc.write("m", [1], { name: "x" }); expect(fetch).toHaveBeenCalled(); }); @@ -39,15 +45,15 @@ describe("RpcService", () => { it("throws SwcError on RPC failure envelope", async () => { vi.mocked(fetch).mockResolvedValue({ ok: true, - json: async () => ({ ok: false, error: { message: "denied" } }), + json: async () => ({ success: false, error: { message: "denied", context: { code: "ACCESS_DENIED" } } }), } as Response); - const rpc = new RpcService("/web/swc/rpc", "tok"); + const rpc = testRpc(); await expect(rpc.read("m", [1])).rejects.toBeInstanceOf(SwcError); }); it("throws on HTTP error status", async () => { vi.mocked(fetch).mockResolvedValue({ ok: false, status: 502 } as Response); - const rpc = new RpcService("/web/swc/rpc", "tok"); + const rpc = testRpc(); await expect(rpc.read("m", [1])).rejects.toBeInstanceOf(SwcError); }); @@ -55,7 +61,7 @@ describe("RpcService", () => { const assign = vi.fn(); vi.stubGlobal("location", { pathname: "/web/home", search: "", assign }); vi.mocked(fetch).mockResolvedValue({ ok: false, status: 401 } as Response); - const rpc = new RpcService("/web/swc/rpc", "tok"); + const rpc = testRpc(); await expect(rpc.read("m", [1])).rejects.toBeInstanceOf(SwcError); expect(assign).toHaveBeenCalledWith("/web/login?next=%2Fweb%2Fhome"); }); @@ -63,13 +69,13 @@ describe("RpcService", () => { it("create and unlink call dispatch", async () => { vi.mocked(fetch).mockResolvedValue({ ok: true, - json: async () => ({ ok: true, result: 1 }), + json: async () => ({ success: true, data: 1 }), } as Response); - const rpc = new RpcService("/web/swc/rpc", "tok"); + const rpc = testRpc(); await rpc.create("m", { name: "a" }); vi.mocked(fetch).mockResolvedValue({ ok: true, - json: async () => ({ ok: true, result: true }), + json: async () => ({ success: true, data: true }), } as Response); await rpc.unlink("m", [1]); expect(fetch).toHaveBeenCalledTimes(2); @@ -78,9 +84,9 @@ describe("RpcService", () => { it("callMethod, onchange, and readGroup dispatch", async () => { vi.mocked(fetch).mockResolvedValue({ ok: true, - json: async () => ({ ok: true, result: { value: {} } }), + json: async () => ({ success: true, data: { value: {} } }), } as Response); - const rpc = new RpcService("/web/swc/rpc", "tok"); + const rpc = testRpc(); await rpc.callMethod("m", "action_x", 3, { foo: "bar" }); await rpc.onchange("m", { name: "a" }, "name"); await rpc.readGroup("m", [], ["amount"], ["state"]); diff --git a/core/swc/tests/shell/app-launcher.test.ts b/core/swc/tests/shell/app-launcher.test.ts index f03148dc..7fe7a082 100644 --- a/core/swc/tests/shell/app-launcher.test.ts +++ b/core/swc/tests/shell/app-launcher.test.ts @@ -12,6 +12,7 @@ function sampleBoot(): SwcBootstrap { return { csrfToken: "t", rpcUrl: "/api/rpc", + apiDatabase: "test_db", swcApiBase: "/web/swc", user: { id: 1, name: "Admin", login: "admin", initials: "A" }, company: { id: 1, name: "Main" }, diff --git a/core/swc/tests/views/view-toolbar.test.ts b/core/swc/tests/views/view-toolbar.test.ts index 0e233a0e..a440e4bd 100644 --- a/core/swc/tests/views/view-toolbar.test.ts +++ b/core/swc/tests/views/view-toolbar.test.ts @@ -44,6 +44,7 @@ describe("view-toolbar", () => { expect(listParams.get("action")).toBe("12"); expect(listParams.get("fields")).toBe("name,email"); expect(listParams.get("id")).toBeNull(); + expect(listParams.get("csrf_token")).toBeNull(); const formParams = exportQuery(basePayload({ recordId: 7 }), "name,email", 7); expect(formParams.get("id")).toBe("7"); diff --git a/docs/ops-runbook.md b/docs/ops-runbook.md index b93d52a4..4315932b 100644 --- a/docs/ops-runbook.md +++ b/docs/ops-runbook.md @@ -9,6 +9,12 @@ Alpha kernel — use for pilots and staging only. See [platform-status.md](platf 3. Start: `./sumeru -c sumeru.conf` or `docker compose -f docker-compose.prod.yml up --build`. 4. Probes: `GET /api/health` (live), `GET /api/ready` (database). +### HTTPS and HTTP/2 (RPC + web) + +Set **`tls_cert_file`** and **`tls_key_file`** in `sumeru.conf` (see `sumeru.conf.example`). The process listens with TLS; Go negotiates **HTTP/2** via ALPN on that listener. Terminate TLS at a reverse proxy instead when you prefer — ensure the proxy speaks HTTP/2 to clients and HTTP/1.1 or h2 to the app as appropriate. + +Integrators call **`POST /api/rpc/auth.login`** (database + tenant headers, no Bearer), then **`POST /api/rpc/{model}/{method}`** with **`Authorization: Bearer `** from the login response. + Optional shell presence (off by default): - `close_session_on_last_tab = true` — SWC tab lease + `POST /web/session/touch` heartbeat; when all tabs stop, session ends after `session_browser_grace_seconds`. diff --git a/docs/orm-sql-policy.md b/docs/orm-sql-policy.md new file mode 100644 index 00000000..a39f2468 --- /dev/null +++ b/docs/orm-sql-policy.md @@ -0,0 +1,38 @@ +# ORM and raw SQL policy (kernel) + +Sumeru separates **business data access** from **infrastructure SQL**. + +## Tier A — RPC and business mutations (ORM only) + +All [`POST /api/rpc/{model}/{method}`](../core/server/web/rpc_json.go) model operations go through [`core/server/api`](../core/server/api/) into [`core/orm`](../core/orm/) (`Search`, `Create`, `UpdateRecordByID`, `Unlink`, `ReadGroup`, etc.). Record rules, model ACL, and field ACL apply. [`POST /api/rpc/auth.login`](../core/server/web/rpc_auth.go) uses ORM helpers in [`user_auth.go`](../core/orm/user_auth.go). + +## Tier B — Web handlers on business models + +Handlers that touch tenant/business models should use ORM helpers, not ad-hoc `UPDATE`/`SELECT`. Examples migrated to ORM: + +- Active company switch → [`SetUserActiveCompany`](../core/orm/user_active_company.go) +- Event log listing → `SearchPage` on `app.log` +- Menu child walk → `SearchPage` on `sys.menu` + +## Tier C — Infrastructure SQL (allowlisted) + +These paths may use parameterized SQL via `orm.DB` with [`MustQuotedTableName`](../core/orm/db_wrapper.go): + +| Area | Files | Reason | +|------|-------|--------| +| Sessions | `core/server/web/auth.go`, `session_browser.go` | Cookie session store | +| Login | — | Credential verify via `orm.AuthenticateLocalUser` (ORM read on `core.user`) | +| Health | `core/server/web/rpc_json.go` | `Ping()` only | +| Tests | `core/server/web/testexports.go` | Test session fixtures | +| ORM kernel | `core/orm/*` | Query builder implementation | +| Module sync | `core/module/*` | Elevated install/sync | +| Engine menus | `core/engine/render/menus.go` | Performance (review when changing) | + +CI enforces that **new** raw SQL in `core/server/web` stays within the allowlist ([`scripts/check_sql_allowlist.sh`](../scripts/check_sql_allowlist.sh)). + +## Verification + +```bash +cd sumeru && bash scripts/check_sql_allowlist.sh +cd sumeru && bash scripts/check_sql_safety.sh +``` diff --git a/docs/platform-status.md b/docs/platform-status.md index d6bd3214..b597388c 100644 --- a/docs/platform-status.md +++ b/docs/platform-status.md @@ -11,7 +11,7 @@ Maturity of the **`sumeru` repository** (Go engine + `addons/`). Business apps i | Modules & ORM | **Ready** | Manifests, install/update, compute, domains, multi-company, elevated context | | Security | **Ready** | Groups, record rules, model ACL, field ACL UI | | Views & SWC | **Ready** | List, form, kanban, graph, pivot, calendar, gantt, map, cohort, hierarchy, activity; view inherit | -| JSON-RPC | **Ready** | `POST /api/rpc` (session or API key) — primary integration API | +| JSON-RPC | **Ready** | `POST /api/rpc/{model}/{method}` + `POST /api/rpc/auth.login` (session or API key) — primary integration API | | Reports, import/export | **Ready** | Document reports; import wizard; async export / bulk jobs (`platform`) | | Mail & activities | **Partial** | `mail` addon: messages, followers, notifications, outbound queue, activity board; no inbound IMAP | | Realtime | **Ready** | `sys.bus.event`, PG NOTIFY, SWC WebSocket reconnect | diff --git a/scripts/apply_literal_first_comparisons.py b/scripts/apply_literal_first_comparisons.py new file mode 100644 index 00000000..ca2dc910 --- /dev/null +++ b/scripts/apply_literal_first_comparisons.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Rewrite common Go comparisons to literal-first form under core/ and addons/.""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DIRS = [ROOT / "core", ROOT / "addons"] + +SIMPLE_ERR_LEN = re.compile( + r"if err != nil \|\| len\(([^)]+)\) == 0 \{\n(\s+)([^\n]+)\n\2\}", +) +REMAINING_ERR_LEN = re.compile( + r"if err != nil \|\| len\(([^)]+)\) == 0", +) +LEN_EQ0 = re.compile(r"\blen\(([^)]+)\) == 0\b") +# Do not match struct fields (e.g. gid.Int64 == 0). +ID_LE0 = re.compile(r"(? str: + def split_simple(match: re.Match[str]) -> str: + inner = match.group(1) + indent = match.group(2) + body = match.group(3) + return ( + f"if err != nil {{\n{indent}{body}\n{indent}}}\n" + f"if 0 == len({inner}) {{\n{indent}{body}\n{indent}}}\n" + ) + + out = SIMPLE_ERR_LEN.sub(split_simple, source) + out = REMAINING_ERR_LEN.sub(r"if err != nil || 0 == len(\1)", out) + out = LEN_EQ0.sub(r"0 == len(\1)", out) + out = ID_LE0.sub(lambda m: f"0 >= {m.group(1)}", out) + + def repl_eq0(match: re.Match[str]) -> str: + name = match.group(1) + if name in SKIP_EQ0: + return match.group(0) + return f"0 == {name}" + + out = ID_EQ0.sub(repl_eq0, out) + return out + + +def main() -> int: + changed = 0 + for base in DIRS: + if not base.is_dir(): + continue + for path in sorted(base.rglob("*.go")): + text = path.read_text(encoding="utf-8") + updated = transform(text) + if updated != text: + path.write_text(updated, encoding="utf-8") + changed += 1 + print(f"updated {changed} Go files") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_sql_allowlist.sh b/scripts/check_sql_allowlist.sh new file mode 100755 index 00000000..dd223efe --- /dev/null +++ b/scripts/check_sql_allowlist.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Fail when core/server/web uses orm.DB outside the infrastructure allowlist. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +ALLOWED=( + auth.go + login.go + rpc_json.go + session_browser.go + testexports.go +) + +violations=() +while IFS= read -r line; do + file="${line%%:*}" + base="${file##*/}" + allowed=false + for a in "${ALLOWED[@]}"; do + if [[ "$base" == "$a" ]]; then + allowed=true + break + fi + done + if ! $allowed; then + violations+=("$line") + fi +done < <(rg -n 'orm\.DB\.(Query|Exec)' core/server/web --glob '*.go' || true) + +if ((${#violations[@]} > 0)); then + echo "orm.DB usage outside web SQL allowlist:" >&2 + printf ' %s\n' "${violations[@]}" >&2 + echo "See docs/orm-sql-policy.md — use ORM or add a reviewed allowlist entry." >&2 + exit 1 +fi + +echo "sql allowlist check passed" diff --git a/sumeru.conf.example b/sumeru.conf.example index d2a3c5b1..bb75c619 100644 --- a/sumeru.conf.example +++ b/sumeru.conf.example @@ -63,10 +63,18 @@ log_rolling = false # session_browser_ping_seconds = 30 # session_browser_grace_seconds = 90 +# TLS (optional): enable HTTPS + HTTP/2 (ALPN) for RPC and web on this listener. +# tls_cert_file = /path/to/fullchain.pem +# tls_key_file = /path/to/privkey.pem + # Rate limiting (requests per minute per client IP on /api/rpc and /web/login). # When dev_mode=false and this is unset/0, the server defaults to 120. rate_limit_rpm = 120 +# Public URL for OAuth redirect_uri (e.g. https://erp.example.com). Required in production +# when the app is not reached via localhost or a trusted_proxies peer. +# web_base_url = https://erp.example.com + # Comma-separated CIDRs/IPs of reverse proxies allowed to set X-Forwarded-For. # Empty (default) = never trust XFF; client IP is always RemoteAddr for login/RPC rate limits. # Does not affect setup wizard auth (setup always uses direct RemoteAddr). diff --git a/test/cmd/smoke_test.go b/test/cmd/smoke_test.go index ca0b0aff..cd36a227 100644 --- a/test/cmd/smoke_test.go +++ b/test/cmd/smoke_test.go @@ -4,11 +4,35 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "sumeru/test/harness" ) +func TestSumeruBpScaffoldsI18n(t *testing.T) { + root := harness.RepoRoot(t) + out := t.TempDir() + cmd := exec.Command("go", "run", "./cmd/sumeru-bp", "-name", "bp_i18n_demo", "-out", out) + cmd.Dir = root + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("scaffold: %v\n%s", err, output) + } + catalog, err := os.ReadFile(filepath.Join(out, "bp_i18n_demo", "i18n", "en_US.po")) + if err != nil { + t.Fatal(err) + } + text := string(catalog) + if !strings.Contains(text, "Language: en_US") || !strings.Contains(text, "Bp I18n Demo") { + t.Fatalf("catalog missing source strings:\n%s", text) + } + again := exec.Command("go", "run", "./cmd/sumeru-bp", "-name", "bp_i18n_demo", "-out", out) + again.Dir = root + if err := again.Run(); err == nil { + t.Fatal("expected non-zero exit when the module directory already exists") + } +} + func TestSumeruModuleUsage(t *testing.T) { root := harness.RepoRoot(t) cmd := exec.Command("go", "run", "./cmd/sumeru-module") diff --git a/test/core/api/envelope_test.go b/test/core/api/envelope_test.go index 1f7779f7..e2aef69a 100644 --- a/test/core/api/envelope_test.go +++ b/test/core/api/envelope_test.go @@ -12,11 +12,11 @@ import ( func TestSuccess_and_Fail(t *testing.T) { ok := api.Success([]int{1}) - if !ok.OK || ok.Error != nil || ok.Result == nil { + if !ok.Success || ok.Error != nil || ok.Data == nil { t.Fatalf("Success: %+v", ok) } fail := api.Fail(api.CodeValidationError, "bad", map[string]interface{}{"field": "x"}) - if fail.OK || fail.Error == nil || fail.Error.Code != api.CodeValidationError { + if fail.Success || fail.Error == nil || fail.Error.Code != api.CodeValidationError { t.Fatalf("Fail: %+v", fail) } } @@ -34,15 +34,15 @@ func TestWriteResponse(t *testing.T) { if err := json.Unmarshal(rr.Body.Bytes(), &m); err != nil { t.Fatal(err) } - if m["ok"] != false { - t.Fatalf("ok = %v", m["ok"]) + if m["success"] != false { + t.Fatalf("success = %v", m["success"]) } } func TestResponseFromError(t *testing.T) { t.Run("nil", func(t *testing.T) { resp, status := api.ResponseFromError(nil) - if status != http.StatusOK || !resp.OK { + if status != http.StatusOK || !resp.Success { t.Fatalf("resp=%+v status=%d", resp, status) } }) diff --git a/test/core/api/helpers_test.go b/test/core/api/helpers_test.go index 2abd8127..1794212b 100644 --- a/test/core/api/helpers_test.go +++ b/test/core/api/helpers_test.go @@ -20,13 +20,13 @@ func assertDispatch(t *testing.T, ctx context.Context, body string, wantStatus i t.Fatalf("status = %d; want %d (body=%s)", status, wantStatus, body) } if wantStatus == http.StatusOK { - if !resp.OK { - t.Fatalf("ok = false; want true (body=%s)", body) + if !resp.Success { + t.Fatalf("success = false; want true (body=%s)", body) } return } - if resp.OK { - t.Fatalf("ok = true; want false (body=%s)", body) + if resp.Success { + t.Fatalf("success = true; want false (body=%s)", body) } if resp.Error == nil { t.Fatal("error is nil") diff --git a/test/core/api/rpc_audit_matrix_test.go b/test/core/api/rpc_audit_matrix_test.go new file mode 100644 index 00000000..0e6957e4 --- /dev/null +++ b/test/core/api/rpc_audit_matrix_test.go @@ -0,0 +1,71 @@ +package api_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "sumeru/core/server/api" + "sumeru/core/server/web" +) + +func TestRPCJSONHandler_unauthenticatedEnvelope(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, web.TestRPCURLForTest("core.user", "search"), strings.NewReader(`{"domain":[]}`)) + req.Header.Set("Content-Type", "application/json") + web.ApplyDefaultRPCHeadersForTest(req) + web.RPCJSONHandler(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d; want 401", rr.Code) + } + env := decodeRPCEnvelope(t, rr) + if env.Success || env.Error == nil || env.Error.Code != api.CodeUnauthorized { + t.Fatalf("env = %+v", env) + } + if env.Error.Message == "" { + t.Fatal("expected non-empty error message") + } +} + +func rpcSessionRequest(model, method, body string) *http.Request { + req := httptest.NewRequest(http.MethodPost, web.TestRPCURLForTest(model, method), strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + web.ApplyDefaultRPCHeadersForTest(req) + req.AddCookie(&http.Cookie{Name: web.TestSessionCookieName, Value: "rpc-audit-test-sid"}) + req.Header.Set("X-CSRF-Token", web.CSRFTokenForRequestForTest(req)) + return req +} + +func TestRPCJSONHandler_methodNotAllowedEnvelope(t *testing.T) { + web.SetTestSessionUserIDForTest(1) + t.Cleanup(web.ResetTestSessionUserIDForTest) + + rr := httptest.NewRecorder() + web.RPCJSONHandler(rr, rpcSessionRequest("core.user", "drop_database", `{"domain":[]}`)) + + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d; want 404", rr.Code) + } + env := decodeRPCEnvelope(t, rr) + if env.Error == nil || env.Error.Code != api.CodeMethodNotAllowed { + t.Fatalf("env = %+v", env) + } +} + +func TestRPCJSONHandler_unknownModelEnvelope(t *testing.T) { + web.SetTestSessionUserIDForTest(1) + t.Cleanup(web.ResetTestSessionUserIDForTest) + + rr := httptest.NewRecorder() + web.RPCJSONHandler(rr, rpcSessionRequest("evil.model", "search", `{"domain":[]}`)) + + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d; want 404", rr.Code) + } + env := decodeRPCEnvelope(t, rr) + if env.Error == nil || env.Error.Code != api.CodeModelNotFound { + t.Fatalf("env = %+v", env) + } +} diff --git a/test/core/api/rpc_auth_integration_test.go b/test/core/api/rpc_auth_integration_test.go new file mode 100644 index 00000000..ff0752fc --- /dev/null +++ b/test/core/api/rpc_auth_integration_test.go @@ -0,0 +1,145 @@ +//go:build integration + +package api_test + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + _ "github.com/lib/pq" + + "sumeru/core/orm" + "sumeru/core/server/config" + "sumeru/core/server/web" +) + +const rpcAuthTestPasswordHash = "$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi" // "password" + +func rpcAuthIntegrationDB(t *testing.T) context.Context { + t.Helper() + dsn := os.Getenv("SUMERU_TEST_DSN") + if dsn == "" { + t.Skip("SUMERU_TEST_DSN not set") + } + preflight, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := preflight.Ping(); err != nil { + _ = preflight.Close() + t.Fatalf("db ping: %v", err) + } + _ = preflight.Close() + + orm.InitDBWithPool(dsn, orm.DBPoolSettings{MaxOpenConns: 5, MaxIdleConns: 2}) + if !orm.IsInitialized() { + t.Skip("database schema not bootstrapped (run sumeru -i base)") + } + if config.AppConfig.DbName == "" { + config.AppConfig.DbName = "sumeru" + } + ctx := orm.ContextWithBypass(context.Background(), true) + return orm.ContextWithUID(ctx, 1) +} + +func TestRPCAuthLogin_successAndBearerRPC(t *testing.T) { + ctx := rpcAuthIntegrationDB(t) + userModel, ok := orm.Registry["core.user"] + if !ok { + t.Fatal("core.user not registered") + } + login := fmt.Sprintf("rpc_auth_%d@test.local", time.Now().UnixNano()) + userID, err := orm.Create(ctx, userModel, map[string]interface{}{ + "login": login, + "name": "RPC Auth Test", + }) + if err != nil { + t.Fatalf("create user: %v", err) + } + t.Cleanup(func() { + _, _ = orm.Unlink(ctx, userModel, []int{userID}) + }) + if err := orm.SetUserPasswordHash(ctx, userID, rpcAuthTestPasswordHash); err != nil { + t.Fatalf("SetUserPasswordHash: %v", err) + } + + web.ResetLoginLockoutForTest() + t.Cleanup(web.ResetLoginLockoutForTest) + + loginBody := fmt.Sprintf(`{"login":%q,"password":"password"}`, login) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, web.TestAuthLoginURLForTest(), strings.NewReader(loginBody)) + req.Header.Set("Content-Type", "application/json") + web.ApplyDefaultRPCHeadersForTest(req) + web.RPCJSONHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rr.Code, rr.Body.String()) + } + var env struct { + Success bool `json:"success"` + Data struct { + UID int `json:"uid"` + APIKey string `json:"api_key"` + SessionID string `json:"session_id"` + } `json:"data"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil { + t.Fatalf("decode: %v", err) + } + if !env.Success || env.Data.UID != userID || env.Data.APIKey == "" || !strings.HasPrefix(env.Data.APIKey, "sk_") { + t.Fatalf("bad success payload: %+v", env) + } + if env.Data.SessionID == "" { + t.Fatal("expected session_id") + } + + rpcRR := httptest.NewRecorder() + rpcReq := httptest.NewRequest(http.MethodPost, web.TestRPCURLForTest("core.user", "search"), strings.NewReader(`{"domain":[["id","=",`+fmt.Sprint(userID)+`]],"fields":["login"],"limit":1}`)) + rpcReq.Header.Set("Content-Type", "application/json") + rpcReq.Header.Set("Authorization", "Bearer "+env.Data.APIKey) + web.ApplyDefaultRPCHeadersForTest(rpcReq) + web.RPCJSONHandler(rpcRR, rpcReq) + if rpcRR.Code != http.StatusOK { + t.Fatalf("Bearer RPC status = %d body = %s", rpcRR.Code, rpcRR.Body.String()) + } + rpcEnv := decodeRPCEnvelope(t, rpcRR) + if !rpcEnv.Success { + t.Fatalf("RPC success = false: %+v", rpcEnv.Error) + } +} + +func TestAuthenticateLocalUser_integration(t *testing.T) { + ctx := rpcAuthIntegrationDB(t) + userModel := orm.Registry["core.user"] + login := fmt.Sprintf("orm_auth_%d@test.local", time.Now().UnixNano()) + userID, err := orm.Create(ctx, userModel, map[string]interface{}{ + "login": login, + "name": "ORM Auth Test", + }) + if err != nil { + t.Fatalf("create user: %v", err) + } + t.Cleanup(func() { + _, _ = orm.Unlink(ctx, userModel, []int{userID}) + }) + if err := orm.SetUserPasswordHash(ctx, userID, rpcAuthTestPasswordHash); err != nil { + t.Fatalf("SetUserPasswordHash: %v", err) + } + uid, username, name, err := orm.AuthenticateLocalUser(ctx, login, "password") + if err != nil || uid != userID || username != login || name == "" { + t.Fatalf("AuthenticateLocalUser = uid=%d name=%q err=%v", uid, name, err) + } + _, _, _, err = orm.AuthenticateLocalUser(ctx, login, "wrong") + if err == nil { + t.Fatal("expected error for wrong password") + } +} diff --git a/test/core/api/rpc_auth_test.go b/test/core/api/rpc_auth_test.go new file mode 100644 index 00000000..1dac6c30 --- /dev/null +++ b/test/core/api/rpc_auth_test.go @@ -0,0 +1,79 @@ +package api_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "sumeru/core/server/api" + "sumeru/core/server/web" +) + +func TestRPCAuthLogin_validationErrors(t *testing.T) { + t.Run("missing password", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, web.TestAuthLoginURLForTest(), strings.NewReader(`{"login":"u@example.com"}`)) + req.Header.Set("Content-Type", "application/json") + web.ApplyDefaultRPCHeadersForTest(req) + web.RPCJSONHandler(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d; want 400", rr.Code) + } + env := decodeRPCEnvelope(t, rr) + if env.Success || env.Error == nil || env.Error.Code != api.CodeValidationError { + t.Fatalf("error = %+v; want VALIDATION", env.Error) + } + }) + + t.Run("rejects bearer on login", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, web.TestAuthLoginURLForTest(), strings.NewReader(`{"login":"u","password":"p"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer sk_test") + web.ApplyDefaultRPCHeadersForTest(req) + web.RPCJSONHandler(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d; want 400", rr.Code) + } + }) + + t.Run("GET not allowed", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, web.TestAuthLoginURLForTest(), nil) + web.ApplyDefaultRPCHeadersForTest(req) + web.RPCJSONHandler(rr, req) + if rr.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d; want 405", rr.Code) + } + }) +} + +func TestRPCAuthLogin_invalidCredentialsEnvelope(t *testing.T) { + web.ResetLoginLockoutForTest() + t.Cleanup(web.ResetLoginLockoutForTest) + + rr := httptest.NewRecorder() + body := `{"login":"no-such-user@example.com","password":"wrong"}` + req := httptest.NewRequest(http.MethodPost, web.TestAuthLoginURLForTest(), strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + web.ApplyDefaultRPCHeadersForTest(req) + web.RPCJSONHandler(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d; want 401", rr.Code) + } + env := decodeRPCEnvelope(t, rr) + if env.Success { + t.Fatal("success = true; want false") + } + if env.Error == nil || env.Error.Code != "INVALID_CREDENTIALS" { + t.Fatalf("error = %+v; want INVALID_CREDENTIALS", env.Error) + } + if env.Error.Type != "AuthenticationError" { + t.Fatalf("error.type = %q; want AuthenticationError", env.Error.Type) + } + if strings.Contains(rr.Body.String(), "sk_") { + t.Fatal("response must not contain api key material") + } +} diff --git a/test/core/api/rpc_csrf_test.go b/test/core/api/rpc_csrf_test.go new file mode 100644 index 00000000..c5316f73 --- /dev/null +++ b/test/core/api/rpc_csrf_test.go @@ -0,0 +1,33 @@ +package api_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "sumeru/core/server/api" + "sumeru/core/server/web" +) + +func TestRPCJSONHandler_sessionRequiresCSRF(t *testing.T) { + web.SetTestSessionUserIDForTest(42) + t.Cleanup(web.ResetTestSessionUserIDForTest) + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, web.TestRPCURLForTest("core.user", "search"), strings.NewReader(`{"domain":[]}`)) + req.Header.Set("Content-Type", "application/json") + web.ApplyDefaultRPCHeadersForTest(req) + web.RPCJSONHandler(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d; want %d", rr.Code, http.StatusForbidden) + } + env := decodeRPCEnvelope(t, rr) + if env.Success { + t.Fatal("success = true; want false") + } + if env.Error == nil || env.Error.Code != api.CodeAccessDenied { + t.Fatalf("error = %+v; want ACCESS_DENIED", env.Error) + } +} diff --git a/test/core/api/rpc_dispatch_test.go b/test/core/api/rpc_dispatch_test.go index 0ec4d8ec..189327fa 100644 --- a/test/core/api/rpc_dispatch_test.go +++ b/test/core/api/rpc_dispatch_test.go @@ -189,11 +189,23 @@ func TestDispatch_methodValidation(t *testing.T) { func TestDispatch_readEmptyIDs(t *testing.T) { body := `{"model":"sys.session","method":"read","args":[[]]}` resp, status := api.Dispatch(authCtx(), []byte(body)) - if status != http.StatusOK || !resp.OK { + if status != http.StatusOK || !resp.Success { t.Fatalf("status=%d resp=%+v", status, resp) } } +func TestDispatch_writeUnlinkTooManyIDs(t *testing.T) { + ids := make([]string, 0, 501) + for i := 1; i <= 501; i++ { + ids = append(ids, "1") + } + idJSON := "[" + strings.Join(ids, ",") + "]" + writeBody := `{"model":"sys.session","method":"write","args":[` + idJSON + `,{"sid":"x"}]}` + assertDispatch(t, authCtx(), writeBody, http.StatusBadRequest, api.CodeInvalidArgs) + unlinkBody := `{"model":"sys.session","method":"unlink","args":[` + idJSON + `]}` + assertDispatch(t, authCtx(), unlinkBody, http.StatusBadRequest, api.CodeInvalidArgs) +} + func TestDispatch_readTooManyIDs(t *testing.T) { ids := make([]string, 0, 501) for i := 1; i <= 501; i++ { @@ -209,7 +221,7 @@ func TestDispatch_writeUnlinkEmptyIDs(t *testing.T) { `{"model":"sys.session","method":"unlink","args":[[]]}`, } { resp, status := api.Dispatch(authCtx(), []byte(body)) - if status != http.StatusOK || !resp.OK || resp.Result != true { + if status != http.StatusOK || !resp.Success || resp.Data != true { t.Fatalf("body=%s status=%d resp=%+v", body, status, resp) } } @@ -221,7 +233,7 @@ func TestDispatch_writeManyEmptyIDs(t *testing.T) { if status != http.StatusOK { t.Fatalf("status = %d; want 200 for empty id list", status) } - if !resp.OK || resp.Result != true { + if !resp.Success || resp.Data != true { t.Fatalf("resp = %+v", resp) } } @@ -232,7 +244,7 @@ func TestDispatch_unlinkManyEmptyIDs(t *testing.T) { if status != http.StatusOK { t.Fatalf("status = %d; want 200 for empty id list", status) } - if !resp.OK || resp.Result != true { + if !resp.Success || resp.Data != true { t.Fatalf("resp = %+v", resp) } } diff --git a/test/core/api/rpc_http_test.go b/test/core/api/rpc_http_test.go index 921cd4f7..f9a47764 100644 --- a/test/core/api/rpc_http_test.go +++ b/test/core/api/rpc_http_test.go @@ -6,15 +6,15 @@ import ( "net/http/httptest" "strings" "testing" + "sumeru/core/server/api" "sumeru/core/server/web" ) - type rpcEnvelope struct { - OK bool `json:"ok"` - Result json.RawMessage `json:"result"` - Error *api.RPCError `json:"error"` + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + Error *api.APIError `json:"error"` } func decodeRPCEnvelope(t *testing.T, rr *httptest.ResponseRecorder) rpcEnvelope { @@ -32,33 +32,32 @@ func decodeRPCEnvelope(t *testing.T, rr *httptest.ResponseRecorder) rpcEnvelope func TestRPCJSONHandler_unauthenticatedJSONEnvelope(t *testing.T) { rr := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/rpc", strings.NewReader(`{"model":"core.user","method":"search","args":[]}`)) + req := httptest.NewRequest(http.MethodPost, web.TestRPCURLForTest("core.user", "search"), strings.NewReader(`{"domain":[]}`)) req.Header.Set("Content-Type", "application/json") + web.ApplyDefaultRPCHeadersForTest(req) web.RPCJSONHandler(rr, req) if rr.Code != http.StatusUnauthorized { t.Fatalf("status = %d; want %d", rr.Code, http.StatusUnauthorized) } env := decodeRPCEnvelope(t, rr) - if env.OK { - t.Fatal("ok = true; want false") - } - if env.Error == nil || env.Error.Code != api.CodeUnauthorized { + if env.Success || env.Error == nil || env.Error.Code != api.CodeUnauthorized { t.Fatalf("error = %+v; want UNAUTHORIZED", env.Error) } } func TestRPCJSONHandler_nonPostJSONEnvelope(t *testing.T) { rr := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/rpc", nil) + req := httptest.NewRequest(http.MethodGet, web.TestRPCURLForTest("core.user", "search"), nil) + web.ApplyDefaultRPCHeadersForTest(req) web.RPCJSONHandler(rr, req) if rr.Code != http.StatusMethodNotAllowed { t.Fatalf("status = %d; want %d", rr.Code, http.StatusMethodNotAllowed) } env := decodeRPCEnvelope(t, rr) - if env.OK { - t.Fatal("ok = true; want false") + if env.Success { + t.Fatal("success = true; want false") } if env.Error == nil || env.Error.Code != api.CodeMethodNotAllowed { t.Fatalf("error = %+v; want METHOD_NOT_ALLOWED", env.Error) @@ -66,10 +65,10 @@ func TestRPCJSONHandler_nonPostJSONEnvelope(t *testing.T) { } func TestRPCJSONHandler_unsupportedMediaType(t *testing.T) { - // Handler checks auth before Content-Type; unauthenticated requests still get 401 first. rr := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/rpc", strings.NewReader(`{}`)) + req := httptest.NewRequest(http.MethodPost, web.TestRPCURLForTest("core.user", "search"), strings.NewReader(`{}`)) req.Header.Set("Content-Type", "text/plain") + web.ApplyDefaultRPCHeadersForTest(req) web.RPCJSONHandler(rr, req) if rr.Code != http.StatusUnauthorized { t.Fatalf("status = %d; want 401 before media type check without auth", rr.Code) @@ -77,10 +76,10 @@ func TestRPCJSONHandler_unsupportedMediaType(t *testing.T) { } func TestRPCJSONHandler_emptyBody(t *testing.T) { - // Without auth, empty body still yields 401 (auth runs before dispatch). rr := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/rpc", strings.NewReader("")) + req := httptest.NewRequest(http.MethodPost, web.TestRPCURLForTest("core.user", "search"), strings.NewReader("")) req.Header.Set("Content-Type", "application/json") + web.ApplyDefaultRPCHeadersForTest(req) web.RPCJSONHandler(rr, req) if rr.Code != http.StatusUnauthorized { t.Fatalf("status = %d; want %d", rr.Code, http.StatusUnauthorized) diff --git a/test/core/api/rpc_test.go b/test/core/api/rpc_test.go index 3f32521d..b98b3e9f 100644 --- a/test/core/api/rpc_test.go +++ b/test/core/api/rpc_test.go @@ -130,8 +130,8 @@ func TestDispatch_inputValidation(t *testing.T) { if status != tt.wantStatus { t.Fatalf("status = %d; want %d", status, tt.wantStatus) } - if resp.OK != tt.wantOK { - t.Fatalf("ok = %v; want %v", resp.OK, tt.wantOK) + if resp.Success != tt.wantOK { + t.Fatalf("success = %v; want %v", resp.Success, tt.wantOK) } if tt.wantOK { if resp.Error != nil { @@ -145,8 +145,8 @@ func TestDispatch_inputValidation(t *testing.T) { if resp.Error.Code != tt.wantCode { t.Fatalf("error.code = %q; want %q (msg=%q)", resp.Error.Code, tt.wantCode, resp.Error.Message) } - if resp.Result != nil { - t.Fatalf("result = %v; want nil on failure", resp.Result) + if resp.Data != nil { + t.Fatalf("data = %v; want nil on failure", resp.Data) } }) } @@ -158,7 +158,7 @@ func TestDispatch_paramsWrapper(t *testing.T) { if status != http.StatusNotFound { t.Fatalf("status = %d; want %d", status, http.StatusNotFound) } - if resp.OK || resp.Error == nil || resp.Error.Code != api.CodeModelNotFound { + if resp.Success || resp.Error == nil || resp.Error.Code != api.CodeModelNotFound { t.Fatalf("resp = %+v", resp) } } @@ -174,8 +174,8 @@ func TestDispatch_successEnvelope(t *testing.T) { if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } - if m["ok"] != true { - t.Fatalf("ok = %v", m["ok"]) + if m["success"] != true { + t.Fatalf("success = %v", m["success"]) } if m["error"] != nil { t.Fatalf("error = %v; want null", m["error"]) diff --git a/test/core/orm/attachment_mime_test.go b/test/core/orm/attachment_mime_test.go index 5b69d2cb..68b5d5b8 100644 --- a/test/core/orm/attachment_mime_test.go +++ b/test/core/orm/attachment_mime_test.go @@ -13,6 +13,13 @@ func TestValidateAttachmentMIME_rejectsHTML(t *testing.T) { } } +func TestValidateAttachmentMIME_rejectsSVG(t *testing.T) { + err := orm.ValidateAttachmentMIME("image/svg+xml", []byte("")) + if err == nil { + t.Fatal("expected SVG upload to be rejected") + } +} + func TestValidateAttachmentMIME_allowsPDF(t *testing.T) { if err := orm.ValidateAttachmentMIME("application/pdf", []byte("%PDF-1.4")); err != nil { t.Fatalf("expected PDF allowed: %v", err) diff --git a/test/core/orm/company_create_defaults_test.go b/test/core/orm/company_create_defaults_test.go new file mode 100644 index 00000000..625c6078 --- /dev/null +++ b/test/core/orm/company_create_defaults_test.go @@ -0,0 +1,33 @@ +package orm_test + +import ( + "context" + "testing" + + "sumeru/core/orm" +) + +func TestStampActiveCompanyOnCreate_setsFromContext(t *testing.T) { + orm.RegisterStubModelForTest(t, "test.co.stamp", []orm.FieldDefinition{ + {Name: "name", Type: orm.Char}, + {Name: "company_id", Type: orm.Many2One, Relation: "core.company"}, + }) + ctx := orm.ContextWithCompanyID(orm.ContextWithUID(context.Background(), 7), 3) + prepared := map[string]interface{}{"name": "Row"} + orm.StampActiveCompanyOnCreateForTest(ctx, "test.co.stamp", prepared) + if prepared["company_id"] != 3 { + t.Fatalf("company_id = %v, want 3", prepared["company_id"]) + } +} + +func TestStampActiveCompanyOnCreate_skipsWhenSet(t *testing.T) { + orm.RegisterStubModelForTest(t, "test.co.stamp2", []orm.FieldDefinition{ + {Name: "company_id", Type: orm.Many2One, Relation: "core.company"}, + }) + ctx := orm.ContextWithCompanyID(orm.ContextWithUID(context.Background(), 7), 3) + prepared := map[string]interface{}{"company_id": 9} + orm.StampActiveCompanyOnCreateForTest(ctx, "test.co.stamp2", prepared) + if prepared["company_id"] != 9 { + t.Fatalf("company_id = %v, want 9 (unchanged)", prepared["company_id"]) + } +} diff --git a/test/core/orm/platform_unit_test.go b/test/core/orm/platform_unit_test.go index 35e3f677..89604a59 100644 --- a/test/core/orm/platform_unit_test.go +++ b/test/core/orm/platform_unit_test.go @@ -1,11 +1,10 @@ package orm_test import ( - "testing" "sumeru/core/orm" + "testing" ) - func TestHashAPIKey_stable(t *testing.T) { a := orm.HashAPIKey("sk_test") b := orm.HashAPIKey("sk_test") @@ -88,6 +87,9 @@ func TestTranslate_fallbackWithoutDB(t *testing.T) { if orm.Translate(orm.BackgroundBypass(), "en_US", "X") != "X" { t.Fatal("en_US should return src") } + if orm.UserLang(nil) != "en_US" || orm.UserLang(orm.BackgroundBypass()) != "en_US" { + t.Fatal("missing user should use en_US") + } } func TestCanWorkflowTransition_bypass(t *testing.T) { diff --git a/test/core/orm/read_group_acl_test.go b/test/core/orm/read_group_acl_test.go new file mode 100644 index 00000000..9f1f48ea --- /dev/null +++ b/test/core/orm/read_group_acl_test.go @@ -0,0 +1,59 @@ +package orm_test + +import ( + "context" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + + "sumeru/core/orm" +) + +func TestReadGroupDirectDeniesGroupedField(t *testing.T) { + const model = "test.readgroup.acl" + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + orm.SetDBForTest(orm.NewDBWrapper(db)) + t.Cleanup(func() { + _ = db.Close() + orm.ResetDBForTest() + delete(orm.Registry, model) + }) + orm.RegisterModelWithModule(safetyModel{ + name: model, + fields: []orm.FieldDefinition{ + {Name: "name", Type: orm.Char}, + {Name: "secret", Type: orm.Char, Groups: "base.group_system"}, + }, + }, "test") + + uid := 424242 + orm.InvalidateEffectiveGroups(context.Background(), uid) + emptyGroups := sqlmock.NewRows([]string{"group_id"}) + mock.ExpectQuery(`SELECT group_id FROM "core_group_user_rel"`). + WithArgs(uid). + WillReturnRows(emptyGroups) + mock.ExpectQuery(`SELECT group_id, "perm_read" FROM "sys_access"`). + WithArgs(model). + WillReturnRows(sqlmock.NewRows([]string{"group_id", "perm_read"}).AddRow(nil, true)) + mock.ExpectQuery(`SELECT group_id FROM "core_group_user_rel"`). + WithArgs(uid). + WillReturnRows(sqlmock.NewRows([]string{"group_id"})) + mock.ExpectQuery(`SELECT field_name, group_id, "perm_read" FROM "sys_field_access"`). + WithArgs(model). + WillReturnRows(sqlmock.NewRows([]string{"field_name", "group_id", "perm_read"})) + mock.ExpectQuery(`SELECT group_id FROM "core_group_user_rel"`). + WithArgs(uid). + WillReturnRows(sqlmock.NewRows([]string{"group_id"})) + + _, err = orm.ReadGroupDirect(orm.ContextWithUID(context.Background(), uid), model, nil, orm.ReadGroupSpec{ + GroupBy: []string{"secret"}, + Fields: []orm.ReadGroupField{{Name: "secret_sum", Field: "secret", Measure: "sum"}}, + }) + if err == nil || !strings.Contains(err.Error(), "field access denied") { + t.Fatalf("err %v", err) + } +} diff --git a/test/core/report/bulk_actor_test.go b/test/core/report/bulk_actor_test.go new file mode 100644 index 00000000..44a11e18 --- /dev/null +++ b/test/core/report/bulk_actor_test.go @@ -0,0 +1,18 @@ +package report_test + +import ( + "testing" + + "sumeru/core/orm" + "sumeru/core/report" +) + +func TestBulkJobActorContextDropsBypass(t *testing.T) { + ctx := report.BulkJobActorContextForTest(map[string]interface{}{"user_id": 7}) + if orm.SecurityBypass(ctx) { + t.Fatal("queued job must not keep the scheduler bypass") + } + if orm.UIDFromContext(ctx) != 7 { + t.Fatalf("uid %d", orm.UIDFromContext(ctx)) + } +} diff --git a/test/core/report/bulk_user_guard_test.go b/test/core/report/bulk_user_guard_test.go new file mode 100644 index 00000000..1ae7d6a4 --- /dev/null +++ b/test/core/report/bulk_user_guard_test.go @@ -0,0 +1,20 @@ +package report_test + +import ( + "context" + "testing" + + "sumeru/core/orm" + "sumeru/core/report" +) + +func TestBulkJobActorContextRequiresPositiveUserID(t *testing.T) { + ctx := report.BulkJobActorContextForTest(map[string]interface{}{"user_id": 0}) + if orm.UIDFromContext(ctx) != 0 { + t.Fatalf("uid %d", orm.UIDFromContext(ctx)) + } + if orm.SecurityBypass(ctx) { + t.Fatal("must not carry bypass") + } + _ = context.Background() +} diff --git a/test/core/report/document_html_test.go b/test/core/report/document_html_test.go index 5ed4658d..0161f75b 100644 --- a/test/core/report/document_html_test.go +++ b/test/core/report/document_html_test.go @@ -23,6 +23,22 @@ func TestRenderHTMLTemplate(t *testing.T) { } } +func TestPreviewHTMLFromTemplateEscapesRecordText(t *testing.T) { + report.RegisterHTMLTemplate("test_report_escape", `

{{.Record.name}}

`) + out, err := report.PreviewHTMLFromTemplate("test_report_escape", report.DocumentRenderContext{ + Record: map[string]interface{}{"name": ``}, + }) + if err != nil { + t.Fatal(err) + } + if strings.Contains(out, "