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/cmd/sumeru-bp/templates/init.go.tmpl b/cmd/sumeru-bp/templates/init.go.tmpl index e3be7a5a..518637c6 100644 --- a/cmd/sumeru-bp/templates/init.go.tmpl +++ b/cmd/sumeru-bp/templates/init.go.tmpl @@ -1,6 +1,6 @@ -// Package {{.Name}} provides business logic and module initialization. -package {{.Name}} +// Package {{.ModuleName}} provides business logic and module initialization. +package {{.ModuleName}} import ( - _ "{{.ModelImport}}" + _ "{{.ModelsImport}}" ) diff --git a/cmd/sumeru-bp/templates/kanban_view.xml.tmpl b/cmd/sumeru-bp/templates/kanban_view.xml.tmpl index 0f92e787..2136083b 100644 --- a/cmd/sumeru-bp/templates/kanban_view.xml.tmpl +++ b/cmd/sumeru-bp/templates/kanban_view.xml.tmpl @@ -1,7 +1,7 @@ - + diff --git a/cmd/sumeru-bp/templates/list_view.xml.tmpl b/cmd/sumeru-bp/templates/list_view.xml.tmpl index afc7a537..64d8fb31 100644 --- a/cmd/sumeru-bp/templates/list_view.xml.tmpl +++ b/cmd/sumeru-bp/templates/list_view.xml.tmpl @@ -1,7 +1,7 @@ - + diff --git a/cmd/sumeru-bp/templates/manifest.json.tmpl b/cmd/sumeru-bp/templates/manifest.json.tmpl index c2c80f67..d2408703 100644 --- a/cmd/sumeru-bp/templates/manifest.json.tmpl +++ b/cmd/sumeru-bp/templates/manifest.json.tmpl @@ -1,6 +1,6 @@ { - "name": "{{.Name}}", - "display_name": "{{.HumanTitle}}", + "name": "{{.ModuleName}}", + "display_name": "{{.DisplayName}}", "version": "1.0.0", "author": "Your Company", "description": "Premium standard module for Sumeru ERP.", diff --git a/cmd/sumeru-bp/templates/menus.xml.tmpl b/cmd/sumeru-bp/templates/menus.xml.tmpl index 300dbb74..e09a64ef 100644 --- a/cmd/sumeru-bp/templates/menus.xml.tmpl +++ b/cmd/sumeru-bp/templates/menus.xml.tmpl @@ -2,12 +2,12 @@ - + - + - + diff --git a/cmd/sumeru-bp/templates/models.go.tmpl b/cmd/sumeru-bp/templates/models.go.tmpl index b9a088a4..0060bf56 100644 --- a/cmd/sumeru-bp/templates/models.go.tmpl +++ b/cmd/sumeru-bp/templates/models.go.tmpl @@ -1,13 +1,13 @@ -// Package models contains the business domain objects for {{.Name}}. +// Package models contains the business domain objects for {{.ModuleName}}. package models import ( "sumeru/core/sdk" ) -// {{.TypeName}} is the primary record for {{.HumanTitle}}. -type {{.TypeName}} struct { - sdk.Model `sumeru:"model={{.ModelDotted}}"` +// {{.StructName}} is the primary record for {{.DisplayName}}. +type {{.StructName}} struct { + sdk.Model `sumeru:"model={{.ModelName}}"` Name sdk.String `sumeru:"required,index,string=Name"` Description sdk.Text `sumeru:"string=Description"` diff --git a/cmd/sumeru-bp/templates/security.xml.tmpl b/cmd/sumeru-bp/templates/security.xml.tmpl index db06baff..99a255a1 100644 --- a/cmd/sumeru-bp/templates/security.xml.tmpl +++ b/cmd/sumeru-bp/templates/security.xml.tmpl @@ -2,23 +2,23 @@ - - {{.HumanTitle}} + + {{.DisplayName}} 100 - - {{.HumanTitle}} / User - + + {{.DisplayName}} / User + - - {{.HumanTitle}} / Manager - - + + {{.DisplayName}} / Manager + + diff --git a/cmd/sumeru-bp/templates/sys.access.csv.tmpl b/cmd/sumeru-bp/templates/sys.access.csv.tmpl index c0f843ef..b2e78269 100644 --- a/cmd/sumeru-bp/templates/sys.access.csv.tmpl +++ b/cmd/sumeru-bp/templates/sys.access.csv.tmpl @@ -1,3 +1,3 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_{{.Name}}_user,{{.Name}}.user,{{.ModelDotted}},group_{{.Name}}_user,1,0,0,0 -access_{{.Name}}_manager,{{.Name}}.manager,{{.ModelDotted}},group_{{.Name}}_manager,1,1,1,1 +access_{{.ModuleName}}_user,{{.ModuleName}}.user,{{.ModelName}},group_{{.ModuleName}}_user,1,0,0,0 +access_{{.ModuleName}}_manager,{{.ModuleName}}.manager,{{.ModelName}},group_{{.ModuleName}}_manager,1,1,1,1 diff --git a/cmd/sumeru-bp/utils.go b/cmd/sumeru-bp/utils.go index c4d51deb..cc67fd7f 100644 --- a/cmd/sumeru-bp/utils.go +++ b/cmd/sumeru-bp/utils.go @@ -3,38 +3,51 @@ package main import ( "fmt" "os" + "path/filepath" "strings" ) -// typeName converts technical_name to TechnicalName. -func typeName(technical string) string { - parts := strings.Split(technical, "_") - for i, p := range parts { - if len(p) == 0 { +// goStructName converts sale_order to SaleOrder. +func goStructName(technicalName string) string { + return joinTitleWords(technicalName, "") +} + +// displayName converts sale_order to Sale Order. +func displayName(technicalName string) string { + return joinTitleWords(technicalName, " ") +} + +func joinTitleWords(technicalName, separator string) string { + parts := strings.Split(technicalName, "_") + words := make([]string, 0, len(parts)) + for _, part := range parts { + if part == "" { continue } - parts[i] = strings.ToUpper(p[:1]) + p[1:] + lower := strings.ToLower(part) + words = append(words, strings.ToUpper(lower[:1])+lower[1:]) } - return strings.Join(parts, "") + return strings.Join(words, separator) } -// humanTitle converts technical_name to Technical Name. -func humanTitle(technical string) string { - parts := strings.Split(technical, "_") - for i, p := range parts { - if p == "" { - continue - } - low := strings.ToLower(p) - parts[i] = strings.ToUpper(low[:1]) + low[1:] +// modelsImportPath is the Go import of models/. A relative parent stays +// repo-relative. An absolute parent is made relative to the repo when it +// sits inside it; otherwise the import matches addons//models. +func modelsImportPath(goModule, repoRoot, parentDir, moduleName string) string { + if !filepath.IsAbs(parentDir) { + return goModule + "/" + filepath.ToSlash(filepath.Join(parentDir, moduleName, "models")) + } + rel, err := filepath.Rel(repoRoot, filepath.Join(parentDir, moduleName, "models")) + outside := err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) + if outside { + return goModule + "/addons/" + moduleName + "/models" } - return strings.Join(parts, " ") + return goModule + "/" + filepath.ToSlash(rel) } -// writeOrDie writes content to a file or exits. -func writeOrDie(path, content string) { +func writeFile(path, content string) error { if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - fmt.Fprintf(os.Stderr, "error writing %s: %v\n", path, err) - os.Exit(1) + return fmt.Errorf("write %s: %w", path, err) } + return nil } diff --git a/core/applog/setup.go b/core/applog/setup.go index 5e5bd42a..c2e2cd58 100644 --- a/core/applog/setup.go +++ b/core/applog/setup.go @@ -57,13 +57,13 @@ func SetupFromConfig(c *config.Config) error { Operation: "init", Status: "success", Context: map[string]interface{}{ - "log_enabled": c.LogEnabled, - "log_timezone": logTzName, - "log_stdout": c.LogStdout, - "log_rolling": c.LogRolling, - "log_file": hr.logPath, - "log_max_size_mb": hr.maxSize, - "log_max_backups": hr.maxBackups, + "log_enabled": c.LogEnabled, + "log_timezone": logTzName, + "log_stdout": c.LogStdout, + "log_rolling": c.LogRolling, + "log_file": hr.logPath, + "log_max_size_mb": hr.maxSize, + "log_max_backups": hr.maxBackups, "log_max_age_days": hr.maxAge, }, }) @@ -77,7 +77,7 @@ func buildHandler(c *config.Config, level slog.Level) (handlerResult, error) { } maxSize := c.LogMaxSizeMB - if maxSize <= 0 { + if 0 >= maxSize { maxSize = 100 } maxBackups := c.LogMaxBackups @@ -109,7 +109,7 @@ func buildHandler(c *config.Config, level slog.Level) (handlerResult, error) { } writers = append(writers, w) } - if len(writers) == 0 { + if 0 == len(writers) { return handlerResult{}, fmt.Errorf("no log sink configured: enable log_stdout and/or set log_file") } var out io.Writer diff --git a/core/engine/eval/safe_eval_v2.go b/core/engine/eval/safe_eval_v2.go index 155c5e6b..69556b65 100644 --- a/core/engine/eval/safe_eval_v2.go +++ b/core/engine/eval/safe_eval_v2.go @@ -83,13 +83,13 @@ func parseDomainList(raw string) ([][]interface{}, bool, error) { for i, ch := range inner { switch ch { case '(': - if depth == 0 { + if 0 == depth { start = i } depth++ case ')': depth-- - if depth == 0 && start >= 0 { + if 0 == depth && start >= 0 { triple, err := parseDomainTriple(inner[start : i+1]) if err != nil { return nil, true, err @@ -99,7 +99,7 @@ func parseDomainList(raw string) ([][]interface{}, bool, error) { } } } - if len(domain) == 0 { + if 0 == len(domain) { return nil, false, nil } return domain, true, nil @@ -160,7 +160,7 @@ func splitEvalList(s string) []string { case ')', ']', '}': depth-- case ',': - if depth == 0 { + if 0 == depth { parts = append(parts, s[start:i]) start = i + 1 } diff --git a/core/engine/parser/arch_multiroot.go b/core/engine/parser/arch_multiroot.go index 844140d6..2a0d68ef 100644 --- a/core/engine/parser/arch_multiroot.go +++ b/core/engine/parser/arch_multiroot.go @@ -141,10 +141,10 @@ func promoteNestedForm(v *View) { if v.Chatter == nil { v.Chatter = f.Chatter } - if len(v.Field) == 0 { + if 0 == len(v.Field) { v.Field = f.Field } - if len(v.Group) == 0 { + if 0 == len(v.Group) { v.Group = f.Group } v.Form = nil diff --git a/core/engine/parser/module_root_xml.go b/core/engine/parser/module_root_xml.go index 3c180d5e..43866f75 100644 --- a/core/engine/parser/module_root_xml.go +++ b/core/engine/parser/module_root_xml.go @@ -24,18 +24,18 @@ type actionHelp struct { // Action represents a simplified tag for actions like sys.action.window. type Action struct { - XMLName xml.Name `xml:"action"` - ID string `xml:"id,attr"` - Type string `xml:"type,attr"` - Model string `xml:"model,attr"` - Name string `xml:"name,attr"` - ViewMode string `xml:"view_mode,attr"` - ViewID string `xml:"view_id,attr"` - SearchViewID string `xml:"search_view_id,attr"` - Context string `xml:"context,attr"` - Domain string `xml:"domain,attr"` - URL string `xml:"url,attr"` - Help actionHelp `xml:"help"` + XMLName xml.Name `xml:"action"` + ID string `xml:"id,attr"` + Type string `xml:"type,attr"` + Model string `xml:"model,attr"` + Name string `xml:"name,attr"` + ViewMode string `xml:"view_mode,attr"` + ViewID string `xml:"view_id,attr"` + SearchViewID string `xml:"search_view_id,attr"` + Context string `xml:"context,attr"` + Domain string `xml:"domain,attr"` + URL string `xml:"url,attr"` + Help actionHelp `xml:"help"` } // ToRecord converts an Action to a Record for backward compatibility. diff --git a/core/engine/parser/view_xml.go b/core/engine/parser/view_xml.go index fa22b37e..6648adde 100644 --- a/core/engine/parser/view_xml.go +++ b/core/engine/parser/view_xml.go @@ -23,12 +23,12 @@ type View struct { kanbanQuickCreate bool kanbanDraggable bool bulkUpload bool - Header *Header `xml:"header"` - Sheet *Sheet `xml:"sheet"` - Footer *Footer `xml:"footer"` - Chatter *Chatter `xml:"chatter"` - Field []Field `xml:"field"` - Group []Group `xml:"group"` + Header *Header `xml:"header"` + Sheet *Sheet `xml:"sheet"` + Footer *Footer `xml:"footer"` + Chatter *Chatter `xml:"chatter"` + Field []Field `xml:"field"` + Group []Group `xml:"group"` // Form is a nested under ; promoted onto View then cleared. Form *archFormRoot `xml:"form"` diff --git a/core/engine/render/branding.go b/core/engine/render/branding.go index b98c5d6e..c26cf60c 100644 --- a/core/engine/render/branding.go +++ b/core/engine/render/branding.go @@ -37,7 +37,8 @@ func EnrichShellPageData(ctx context.Context, d *PageData) { d.AppName = AppDisplayName d.LogoURL = shell.LogoURL uidShell := orm.UIDFromContext(ctx) - d.AppsNavAllowed = orm.UserHasGroupXML(ctx, uidShell, "base.group_system") + d.AppsNavAllowed = orm.UserHasGroupXML(ctx, uidShell, "base.group_system") || + orm.UserHasGroupXML(ctx, uidShell, "base.group_module_manager") d.SettingsNavAllowed = orm.UserHasGroupXML(ctx, uidShell, "base.group_user") if strings.TrimSpace(d.BrandLockupHref) == "" { d.BrandLockupHref = HomeWebURL() @@ -45,10 +46,10 @@ func EnrichShellPageData(ctx context.Context, d *PageData) { if strings.TrimSpace(d.HomeNavHref) == "" { d.HomeNavHref = d.BrandLockupHref } - if len(d.AppLauncherJSON) == 0 { + if 0 == len(d.AppLauncherJSON) { d.AppLauncherJSON = BuildAppLauncherJSON(ctx) } - if len(d.PinnedAppsJSON) == 0 { + if 0 == len(d.PinnedAppsJSON) { d.PinnedAppsJSON = BuildPinnedAppsJSON(ctx) } if strings.TrimSpace(d.UserProfileHref) == "" { @@ -111,10 +112,10 @@ func EnrichShellPageData(ctx context.Context, d *PageData) { // Activity dock (Messages + Log) stays visible for logged-in users; IM APIs gate chat separately. d.ActivityEnabled = orm.SecurityUID(ctx) > 0 - if len(d.ExtraScriptURLs) == 0 { + if 0 == len(d.ExtraScriptURLs) { d.ExtraScriptURLs = ExtraScriptURLs } - if d.SWCEnabled && len(d.SwcAddonEntriesJSON) == 0 { + if d.SWCEnabled && 0 == len(d.SwcAddonEntriesJSON) { d.SwcAddonEntriesJSON = BuildSwcAddonEntriesJSON() } if d.SuppressActivityDock { @@ -132,12 +133,25 @@ func (d *PageData) appendShellHooks(ctx context.Context) { } func loadShellCompanies(ctx context.Context) []ShellCompanyOption { - if orm.DB == nil { + uid := orm.UIDFromContext(ctx) + if 0 >= uid || orm.DB == nil { return nil } if _, ok := orm.Registry["core.company"]; !ok { return nil } + allowed, err := orm.UserCompanyIDs(ctx, uid) + if err != nil { + return nil + } + if 0 == len(allowed) { + return nil + } + + allowedSet := make(map[int64]struct{}, len(allowed)) + for _, id := range allowed { + allowedSet[id] = struct{}{} + } tn := orm.MustQuotedTableName("core.company") rows, err := orm.DB.QueryContext(ctx, `SELECT id, name FROM `+tn+` ORDER BY id ASC`) if err != nil { @@ -152,9 +166,10 @@ func loadShellCompanies(ctx context.Context) []ShellCompanyOption { continue } name = strings.TrimSpace(name) - if id > 0 && name != "" { - out = append(out, ShellCompanyOption{ID: id, Name: name}) + if _, ok := allowedSet[int64(id)]; !ok || 0 >= id || name == "" { + continue } + out = append(out, ShellCompanyOption{ID: id, Name: name}) } return out } @@ -172,4 +187,3 @@ func shellCompanyNameFromOptions(opts []ShellCompanyOption, preferID int) string } return "" } - diff --git a/core/engine/render/menu_breadcrumb.go b/core/engine/render/menu_breadcrumb.go index 79d526d2..0ec46641 100644 --- a/core/engine/render/menu_breadcrumb.go +++ b/core/engine/render/menu_breadcrumb.go @@ -79,7 +79,7 @@ func collectMenuAncestors(ctx context.Context, leafID int) []menuCrumb { // splitModuleMenuChain separates the application root menu from descendant menu folders/items. func splitModuleMenuChain(chain []menuCrumb) (module menuCrumb, menus []menuCrumb, ok bool) { - if len(chain) == 0 { + if 0 == len(chain) { return menuCrumb{}, nil, false } return chain[0], chain[1:], true @@ -98,7 +98,7 @@ type BreadcrumbInput struct { // Labels come from sys.menu names, generic view-mode names, and record/model metadata — never addon-specific maps in core. func BuildWorkspaceBreadcrumbs(ctx context.Context, in BreadcrumbInput) []BreadcrumbItem { mid, err := strconv.Atoi(strings.TrimSpace(in.ActiveMenuID)) - if err != nil || mid <= 0 { + if err != nil || 0 >= mid { if label := workspaceRecordBreadcrumbLabel(in); label != "" { return []BreadcrumbItem{{Label: label, Href: ""}} } @@ -142,14 +142,14 @@ func BuildWorkspaceBreadcrumbs(ctx context.Context, in BreadcrumbInput) []Breadc // View (non-primary modes such as graph/pivot; list/kanban/form omit when menu names the screen). if viewLabel := workspaceViewBreadcrumbLabel(in.ViewType); viewLabel != "" { - if len(items) == 0 || items[len(items)-1].Label != viewLabel { + if 0 == len(items) || items[len(items)-1].Label != viewLabel { items = append(items, BreadcrumbItem{Label: viewLabel, Href: ""}) } } // Record (existing row name, or new-form model label from generic UIModelName fallback). if recordLabel := workspaceRecordBreadcrumbLabel(in); recordLabel != "" { - if len(items) == 0 || items[len(items)-1].Label != recordLabel { + if 0 == len(items) || items[len(items)-1].Label != recordLabel { items = append(items, BreadcrumbItem{Label: recordLabel, Href: ""}) } } @@ -162,7 +162,7 @@ func settingsRootMenuID(ctx context.Context, activeMenuID string) int { return 0 } rid, _, err := orm.ResolveXmlId(ctx, "base.menu_settings_root") - if err != nil || rid <= 0 { + if err != nil || 0 >= rid { return 0 } return rid @@ -250,7 +250,7 @@ func BuildAppLogsBreadcrumbs(ctx context.Context, appLogsMenuID int) []Breadcrum items := []BreadcrumbItem{ {Label: "Home", Href: HomeWebURL()}, } - if appLogsMenuID <= 0 { + if 0 >= appLogsMenuID { return items } chain := collectMenuAncestors(ctx, appLogsMenuID) diff --git a/core/engine/render/menus.go b/core/engine/render/menus.go index 0a5c56b1..6ff207c5 100644 --- a/core/engine/render/menus.go +++ b/core/engine/render/menus.go @@ -33,7 +33,7 @@ func sanitizeMenuIcon(s string) string { func LoadShellMenus(ctx context.Context, activeMenuID string) (topMenus []parser.MenuItem, sidebarMenus []SidebarMenu, activeModuleID, shellModuleTitle string) { shellModuleTitle = AppDisplayName allMenus := fetchShellMenus(ctx) - if len(allMenus) == 0 { + if 0 == len(allMenus) { return nil, nil, "", AppDisplayName } @@ -75,14 +75,7 @@ func fetchShellMenus(ctx context.Context) []parser.MenuItem { } defer rows.Close() - lang := "en_US" - if uid := orm.UIDFromContext(ctx); uid > 0 { - if u, err := orm.SearchOne(ctx, "core.user", map[string]interface{}{"id": uid}); err == nil { - if l := strings.TrimSpace(orm.AsString(u["lang"])); l != "" { - lang = l - } - } - } + lang := orm.UserLang(ctx) urlActionHrefs := loadURLActionHrefs(ctx) @@ -135,7 +128,7 @@ func loadURLActionHrefs(ctx context.Context) map[int]string { } for _, row := range rows { id, ok := orm.CoerceInt64(row["id"]) - if !ok || id <= 0 { + if !ok || 0 >= id { continue } url := strings.TrimSpace(orm.AsString(row["url"])) @@ -225,7 +218,7 @@ func menuItemHasWindowOrURLAction(mi parser.MenuItem) bool { } func filterSidebarLinks(links []parser.MenuItem) []parser.MenuItem { - if len(links) == 0 { + if 0 == len(links) { return links } out := links[:0] @@ -307,7 +300,7 @@ func buildSidebarMenus(allMenus []parser.MenuItem, activeModuleID string, menuAl } sortMenuItemsBySequenceName(links) section.SubMenus = filterSidebarLinks(links) - if len(section.SubMenus) == 0 { + if 0 == len(section.SubMenus) { continue } sections = append(sections, section) @@ -335,7 +328,7 @@ func SidebarHasMenus(menus []SidebarMenu) bool { // sortTopBarRootMenus orders application root menus by each menuitem's `sequence` (then `name`), // matching Sumeru XML. func sortTopBarRootMenus(in []parser.MenuItem) []parser.MenuItem { - if len(in) == 0 { + if 0 == len(in) { return in } out := append([]parser.MenuItem{}, in...) @@ -372,11 +365,11 @@ func queryInstalledApplicationNames(ctx context.Context) (map[string]struct{}, e // IsMenuUnderSettingsRoot reports whether activeMenuID is the Settings root or one of its descendants. func IsMenuUnderSettingsRoot(ctx context.Context, activeMenuID string) bool { id64, err := strconv.ParseInt(strings.TrimSpace(activeMenuID), 10, 64) - if err != nil || id64 <= 0 || orm.DB == nil { + if err != nil || 0 >= id64 || orm.DB == nil { return false } rootID, _, err := orm.ResolveXmlId(ctx, "base.menu_settings_root") - if err != nil || rootID == 0 { + if err != nil || 0 == rootID { return false } return orm.MenuHasAncestor(ctx, int(id64), rootID) diff --git a/core/engine/render/page.go b/core/engine/render/page.go index 3becbe01..0da2beeb 100644 --- a/core/engine/render/page.go +++ b/core/engine/render/page.go @@ -10,7 +10,7 @@ import ( // RenderPage executes base.html with the given shell + content data. func RenderPage(ctx context.Context, templatesDir string, data PageData, bootstrapWorkspace *SWCBootstrapWorkspace) (string, error) { EnrichShellPageData(ctx, &data) - if len(data.SWCBootstrapJSON) == 0 { + if 0 == len(data.SWCBootstrapJSON) { data.SWCBootstrapJSON = BuildSWCBootstrapJSON(ctx, data, bootstrapWorkspace) } paths := ShellLayoutTemplateFiles(templatesDir) diff --git a/core/engine/render/pinned_apps.go b/core/engine/render/pinned_apps.go index 1cb108a5..b3b6d67a 100644 --- a/core/engine/render/pinned_apps.go +++ b/core/engine/render/pinned_apps.go @@ -24,7 +24,7 @@ func ParsePinnedAppsJSON(raw string) []string { // SanitizePinnedModuleList drops unknown modules and duplicates; stable order. func SanitizePinnedModuleList(raw []string, allowed map[string]struct{}) []string { - if len(raw) == 0 || len(allowed) == 0 { + if 0 == len(raw) || 0 == len(allowed) { return nil } seen := make(map[string]struct{}, len(raw)) @@ -63,7 +63,7 @@ func AllowedPinModuleNames(ctx context.Context) map[string]struct{} { // PinnedAppsForUser loads and sanitizes pinned modules for the signed-in user. func PinnedAppsForUser(ctx context.Context) ([]string, error) { uid := orm.UIDFromContext(ctx) - if uid <= 0 || orm.DB == nil { + if 0 >= uid || orm.DB == nil { return nil, nil } row, err := orm.SearchOne(ctx, "core.user", map[string]interface{}{"id": uid}) @@ -78,7 +78,7 @@ func PinnedAppsForUser(ctx context.Context) ([]string, error) { // SavePinnedAppsForUser validates and persists pinned modules for the signed-in user. func SavePinnedAppsForUser(ctx context.Context, modules []string) ([]string, error) { uid := orm.UIDFromContext(ctx) - if uid <= 0 { + if 0 >= uid { return nil, fmt.Errorf("not authenticated") } allowed := AllowedPinModuleNames(ctx) @@ -101,9 +101,13 @@ func SavePinnedAppsForUser(ctx context.Context, modules []string) ([]string, err // BuildPinnedAppsJSON returns sanitized pinned modules for shell bootstrap. func BuildPinnedAppsJSON(ctx context.Context) template.JS { mods, err := PinnedAppsForUser(ctx) - if err != nil || len(mods) == 0 { + if err != nil { + return template.JS("[]") + } + if 0 == len(mods) { return template.JS("[]") } + b, err := json.Marshal(mods) if err != nil { return template.JS("[]") diff --git a/core/engine/render/render_types.go b/core/engine/render/render_types.go index c6e942b8..378aa656 100644 --- a/core/engine/render/render_types.go +++ b/core/engine/render/render_types.go @@ -48,8 +48,8 @@ type PageData struct { Title string // legacy / diagnostics; prefer ViewBreadcrumb for UI ViewBreadcrumb string // human label for breadcrumb (not the technical model id) AppName string // product display name (browser tab suffix, header) - ModuleName string - Content template.HTML + ModuleName string + Content template.HTML TopMenus []parser.MenuItem SidebarMenus []SidebarMenu ActiveModuleID string @@ -75,7 +75,7 @@ type PageData struct { ShellCompany string ShellUser string ShellUserLogin string - ShellUserImage template.URL // template.URL so data: URLs are not scrubbed + ShellUserImage template.URL // template.URL so data: URLs are not scrubbed ShellUserImageCrop template.HTMLAttr // inline crop style for shell avatar when image_crop is set UserInitial string // legacy single-letter hint; prefer ShellUserInitials in shell chrome ShellUserInitials string // two-letter avatar label in top bar when no photo @@ -197,10 +197,10 @@ type ViewRecordData struct { // ListSection groups list rows under a foldable header (list group_by v2). type ListSection struct { - Label string - Value string - Count int - Rows []map[string]interface{} + Label string + Value string + Count int + Rows []map[string]interface{} } // PivotData holds aggregated pivot table cells for HTML rendering. diff --git a/core/engine/render/settings_hub_menus.go b/core/engine/render/settings_hub_menus.go index 80546d97..742ebeb4 100644 --- a/core/engine/render/settings_hub_menus.go +++ b/core/engine/render/settings_hub_menus.go @@ -149,7 +149,7 @@ func BuildSettingsHubCategories(ctx context.Context, settingsRootMenuID string) return nil } allMenus := fetchShellMenus(ctx) - if len(allMenus) == 0 { + if 0 == len(allMenus) { return nil } uid := orm.UIDFromContext(ctx) @@ -170,7 +170,7 @@ func BuildSettingsHubCategories(ctx context.Context, settingsRootMenuID string) continue } cat := buildSettingsHubCategory(catNode, allMenus, menuAllowed, seen) - if len(cat.Groups) == 0 { + if 0 == len(cat.Groups) { continue } categories = append(categories, cat) @@ -207,7 +207,7 @@ func BuildSettingsHubCategoriesForTest(allMenus []parser.MenuItem, settingsRootM continue } cat := buildSettingsHubCategory(catNode, allMenus, menuAllowed, seen) - if len(cat.Groups) == 0 { + if 0 == len(cat.Groups) { continue } categories = append(categories, cat) diff --git a/core/engine/render/settings_nav.go b/core/engine/render/settings_nav.go index a07bc457..313860c6 100644 --- a/core/engine/render/settings_nav.go +++ b/core/engine/render/settings_nav.go @@ -13,10 +13,10 @@ import ( const MenuGeneralSettingsXMLID = "base.menu_general_settings" const ( - SettingsHubPath = "/web/settings" - SettingsAccountPath = "/web/settings/account" - SettingsFieldACLPath = "/web/settings/field-acl" - SettingsModelACLPath = "/web/settings/model-acl" + SettingsHubPath = "/web/settings" + SettingsAccountPath = "/web/settings/account" + SettingsFieldACLPath = "/web/settings/field-acl" + SettingsModelACLPath = "/web/settings/model-acl" ) // Menu XML ids for settings security matrix pages (active sidebar highlight). @@ -42,7 +42,7 @@ func menuIDForXMLID(ctx context.Context, xmlID string) string { return "" } id, _, err := orm.ResolveXmlId(ctx, xmlID) - if err != nil || id <= 0 { + if err != nil || 0 >= id { return "" } return strconv.Itoa(id) diff --git a/core/engine/render/swc_addons.go b/core/engine/render/swc_addons.go index 63b24d58..4dd53886 100644 --- a/core/engine/render/swc_addons.go +++ b/core/engine/render/swc_addons.go @@ -14,7 +14,7 @@ import ( // BuildSwcAddonEntriesJSON returns JSON array of SWC entry script URLs for installed addons. func BuildSwcAddonEntriesJSON() template.JS { urls := collectInstalledSwcEntryURLs() - if len(urls) == 0 { + if 0 == len(urls) { return template.JS("[]") } raw, err := json.Marshal(urls) diff --git a/core/engine/render/swc_bootstrap.go b/core/engine/render/swc_bootstrap.go index cd6c5ff6..23a08789 100644 --- a/core/engine/render/swc_bootstrap.go +++ b/core/engine/render/swc_bootstrap.go @@ -21,35 +21,38 @@ type SWCBootstrapWorkspace struct { ListSearch string `json:"listSearch"` } +// swcBootstrap JSON is embedded in the shell for the SWC client. It may include the per-session +// CSRF token and public navigation metadata only — never API keys, raw session ids, or csrf_secret. type swcBootstrap struct { - CSRFToken string `json:"csrfToken"` - RPCURL string `json:"rpcUrl"` - SwcAPIBase string `json:"swcApiBase"` - User swcBootstrapUser `json:"user"` - Company swcBootstrapCompany `json:"company"` - Companies []swcBootstrapCompany `json:"companies"` - ActiveCompanyID int `json:"activeCompanyId"` - ShowCompanySwitcher bool `json:"showCompanySwitcher"` - TopMenus []swcBootstrapMenu `json:"topMenus"` - SidebarMenus []swcBootstrapSidebar `json:"sidebarMenus"` - ActiveModuleID string `json:"activeModuleId"` - ActiveMenuID string `json:"activeMenuId"` - Apps []swcBootstrapApp `json:"apps"` - PinnedApps []string `json:"pinnedApps"` - AppsNavAllowed bool `json:"appsNavAllowed"` - SettingsNavAllowed bool `json:"settingsNavAllowed"` - ActivityEnabled bool `json:"activityEnabled"` - InternalMessagesEnabled bool `json:"internalMessagesEnabled"` - CompanyFormHref string `json:"companyFormHref,omitempty"` - BusEnabled bool `json:"busEnabled"` - CloseSessionOnLastTab bool `json:"closeSessionOnLastTab"` - SessionBrowserPingSeconds int `json:"sessionBrowserPingSeconds"` - SessionBrowserGraceSeconds int `json:"sessionBrowserGraceSeconds"` + CSRFToken string `json:"csrfToken"` + RPCURL string `json:"rpcUrl"` + APIDatabase string `json:"apiDatabase"` + SwcAPIBase string `json:"swcApiBase"` + User swcBootstrapUser `json:"user"` + Company swcBootstrapCompany `json:"company"` + Companies []swcBootstrapCompany `json:"companies"` + ActiveCompanyID int `json:"activeCompanyId"` + ShowCompanySwitcher bool `json:"showCompanySwitcher"` + TopMenus []swcBootstrapMenu `json:"topMenus"` + SidebarMenus []swcBootstrapSidebar `json:"sidebarMenus"` + ActiveModuleID string `json:"activeModuleId"` + ActiveMenuID string `json:"activeMenuId"` + Apps []swcBootstrapApp `json:"apps"` + PinnedApps []string `json:"pinnedApps"` + AppsNavAllowed bool `json:"appsNavAllowed"` + SettingsNavAllowed bool `json:"settingsNavAllowed"` + ActivityEnabled bool `json:"activityEnabled"` + InternalMessagesEnabled bool `json:"internalMessagesEnabled"` + CompanyFormHref string `json:"companyFormHref,omitempty"` + BusEnabled bool `json:"busEnabled"` + CloseSessionOnLastTab bool `json:"closeSessionOnLastTab"` + SessionBrowserPingSeconds int `json:"sessionBrowserPingSeconds"` + SessionBrowserGraceSeconds int `json:"sessionBrowserGraceSeconds"` DocsURL string `json:"docsUrl"` - ProfileURL string `json:"profileUrl"` - Features map[string]bool `json:"features,omitempty"` - Workspace *SWCBootstrapWorkspace `json:"workspace,omitempty"` - Toasts []swcBootstrapToast `json:"toasts,omitempty"` + ProfileURL string `json:"profileUrl"` + Features map[string]bool `json:"features,omitempty"` + Workspace *SWCBootstrapWorkspace `json:"workspace,omitempty"` + Toasts []swcBootstrapToast `json:"toasts,omitempty"` } type swcBootstrapUser struct { @@ -99,26 +102,27 @@ type swcBootstrapToast struct { func BuildSWCBootstrapJSON(ctx context.Context, page PageData, ws *SWCBootstrapWorkspace) template.JS { topMenus, sidebarMenus, activeModuleID, _ := LoadShellMenus(ctx, page.ActiveMenuID) b := swcBootstrap{ - CSRFToken: page.CSRFToken, - RPCURL: "/api/rpc", - SwcAPIBase: "/web/swc", - ActiveModuleID: activeModuleID, - ActiveMenuID: page.ActiveMenuID, - AppsNavAllowed: page.AppsNavAllowed, - SettingsNavAllowed: page.SettingsNavAllowed, - ActivityEnabled: shellActivityEnabled(ctx, page), - InternalMessagesEnabled: im.IMEnabled(ctx), - CompanyFormHref: im.CompanySettingsFormHref(ctx, page.ShellActiveCompanyID), + CSRFToken: page.CSRFToken, + RPCURL: "/api/rpc", + APIDatabase: config.AppConfig.DbName, + SwcAPIBase: "/web/swc", + ActiveModuleID: activeModuleID, + ActiveMenuID: page.ActiveMenuID, + AppsNavAllowed: page.AppsNavAllowed, + SettingsNavAllowed: page.SettingsNavAllowed, + ActivityEnabled: shellActivityEnabled(ctx, page), + InternalMessagesEnabled: im.IMEnabled(ctx), + CompanyFormHref: im.CompanySettingsFormHref(ctx, page.ShellActiveCompanyID), BusEnabled: true, CloseSessionOnLastTab: config.AppConfig.CloseSessionOnLastTab, SessionBrowserPingSeconds: sessionBrowserPingSecondsForBootstrap(), SessionBrowserGraceSeconds: sessionBrowserGraceSecondsForBootstrap(), DocsURL: page.UserDocsHref, - ProfileURL: page.UserProfileHref, - Features: buildSWCFeatureFlags(ctx), - Workspace: ws, - ShowCompanySwitcher: page.ShowCompanySwitcher, - ActiveCompanyID: page.ShellActiveCompanyID, + ProfileURL: page.UserProfileHref, + Features: buildSWCFeatureFlags(ctx), + Workspace: ws, + ShowCompanySwitcher: page.ShowCompanySwitcher, + ActiveCompanyID: page.ShellActiveCompanyID, } for _, m := range topMenus { b.TopMenus = append(b.TopMenus, swcBootstrapMenu{ @@ -157,7 +161,7 @@ func BuildSWCBootstrapJSON(ctx context.Context, page PageData, ws *SWCBootstrapW } b.Apps = parseSWCLauncherApps(page.AppLauncherJSON) pinnedJSON := page.PinnedAppsJSON - if len(pinnedJSON) == 0 { + if 0 == len(pinnedJSON) { pinnedJSON = BuildPinnedAppsJSON(ctx) } b.PinnedApps = parseSWCPinnedApps(pinnedJSON) @@ -217,7 +221,7 @@ func parseSWCPinnedApps(raw template.JS) []string { func sessionBrowserPingSecondsForBootstrap() int { sec := config.AppConfig.SessionBrowserPingSeconds - if sec <= 0 { + if 0 >= sec { return 30 } return sec @@ -225,7 +229,7 @@ func sessionBrowserPingSecondsForBootstrap() int { func sessionBrowserGraceSecondsForBootstrap() int { sec := config.AppConfig.SessionBrowserGraceSeconds - if sec <= 0 { + if 0 >= sec { return 90 } return sec @@ -233,7 +237,7 @@ func sessionBrowserGraceSecondsForBootstrap() int { func buildSWCFeatureFlags(ctx context.Context) map[string]bool { uid := orm.UIDFromContext(ctx) - if uid <= 0 { + if 0 >= uid { return nil } flags := map[string]bool{} @@ -243,7 +247,7 @@ func buildSWCFeatureFlags(ctx context.Context) map[string]bool { if orm.UserHasAnyAccessGroup(ctx, uid, "base.group_system") { flags["debugMenu"] = true } - if len(flags) == 0 { + if 0 == len(flags) { return nil } return flags diff --git a/core/engine/render/swc_page.go b/core/engine/render/swc_page.go index 968f496c..8cc07718 100644 --- a/core/engine/render/swc_page.go +++ b/core/engine/render/swc_page.go @@ -66,8 +66,8 @@ func RenderSWCWorkspace(ctx context.Context, in SWCPageInput) string { Record: recData.Record, RecordID: recData.RecordID, }), - CSRFToken: recData.CSRFToken, - SWCEnabled: true, + CSRFToken: recData.CSRFToken, + SWCEnabled: true, } inlineFlashes, toastFlashes := splitFlashMessages(recData.FlashMessages) pageData.FlashMessages = inlineFlashes diff --git a/core/engine/render/templates_paths.go b/core/engine/render/templates_paths.go index 768ffa9e..74ff8668 100644 --- a/core/engine/render/templates_paths.go +++ b/core/engine/render/templates_paths.go @@ -6,11 +6,11 @@ import "path/filepath" const ( TemplateShellBase = "shell/base.html" - TemplatePartialMenuIcons = "partials/menu_icons.html" - TemplatePartialHomeHub = "partials/home_hub.html" - TemplatePartialAppsModule = "partials/apps_module.html" - TemplatePartialLoginBrand = "partials/login_brand_aside.html" - TemplatePartialAuthHead = "partials/auth_head.html" + TemplatePartialMenuIcons = "partials/menu_icons.html" + TemplatePartialHomeHub = "partials/home_hub.html" + TemplatePartialAppsModule = "partials/apps_module.html" + TemplatePartialLoginBrand = "partials/login_brand_aside.html" + TemplatePartialAuthHead = "partials/auth_head.html" TemplateAuthLogin = "auth/login.html" TemplateAuthTOTP = "auth/totp_login.html" @@ -19,11 +19,13 @@ const ( TemplatePortalHome = "portal/portal_home.html" TemplatePortalShare = "portal/portal_share.html" - TemplatePagesHome = "pages/home_dashboard.html" - TemplatePagesApps = "pages/apps.html" - TemplatePagesAppLogs = "pages/app_logs.html" - TemplatePagesSettingsHub = "pages/settings_hub.html" - TemplatePagesSettingsAccount = "pages/settings_account.html" + TemplatePagesHome = "pages/home_dashboard.html" + TemplatePagesApps = "pages/apps.html" + TemplatePagesAppLogs = "pages/app_logs.html" + TemplatePagesSettingsHub = "pages/settings_hub.html" + TemplatePagesSettingsAccount = "pages/settings_account.html" + TemplatePagesSettingsAuth = "pages/settings_auth.html" + TemplatePagesSettingsMail = "pages/settings_mail.html" TemplatePagesSettingsFieldACL = "pages/settings_field_acl.html" TemplatePagesSettingsModelACL = "pages/settings_model_acl.html" TemplatePagesImportWizard = "pages/import_wizard.html" diff --git a/core/engine/render/ui_models.go b/core/engine/render/ui_models.go index acf0a0de..a8c7db37 100644 --- a/core/engine/render/ui_models.go +++ b/core/engine/render/ui_models.go @@ -55,7 +55,7 @@ func UIModelName(technicalModel string) string { return name } parts := strings.Split(model, ".") - if len(parts) == 0 { + if 0 == len(parts) { return model } last := parts[len(parts)-1] diff --git a/core/engine/render/user_display.go b/core/engine/render/user_display.go index 95562cb4..51056eef 100644 --- a/core/engine/render/user_display.go +++ b/core/engine/render/user_display.go @@ -9,7 +9,7 @@ func UserInitialsFromName(name string) string { return "?" } parts := strings.Fields(name) - if len(parts) == 0 { + if 0 == len(parts) { return "?" } if len(parts) == 1 { @@ -24,7 +24,7 @@ func UserInitialsFromName(name string) string { } a := []rune(parts[0]) b := []rune(parts[len(parts)-1]) - if len(a) == 0 || len(b) == 0 { + if 0 == len(a) || 0 == len(b) { return "?" } return strings.ToUpper(string(a[0]) + string(b[0])) diff --git a/core/engine/render/workspace_tabs.go b/core/engine/render/workspace_tabs.go index 95702ad8..0aa67110 100644 --- a/core/engine/render/workspace_tabs.go +++ b/core/engine/render/workspace_tabs.go @@ -68,7 +68,7 @@ func WorkspaceViewTabs(ctx context.Context, in WorkspaceTabsInput) []ViewSwitchT // viewModeFilterSet returns nil when all modes should be considered; otherwise a set of allowed modes. func viewModeFilterSet(modes []string) map[string]struct{} { - if len(modes) == 0 { + if 0 == len(modes) { return nil } out := make(map[string]struct{}, len(modes)) @@ -78,7 +78,7 @@ func viewModeFilterSet(modes []string) map[string]struct{} { out[mode] = struct{}{} } } - if len(out) == 0 { + if 0 == len(out) { return nil } return out diff --git a/core/engine/render/workspace_url.go b/core/engine/render/workspace_url.go index 308f5924..4c8aad71 100644 --- a/core/engine/render/workspace_url.go +++ b/core/engine/render/workspace_url.go @@ -25,19 +25,19 @@ const ( // Workspace view_type values. const ( - ViewModeList = "list" - ViewModeForm = "form" - ViewModeKanban = "kanban" - ViewModePivot = "pivot" - ViewModeGraph = "graph" - ViewModeCalendar = "calendar" - ViewModeGantt = "gantt" - ViewModeMap = "map" - ViewModeCohort = "cohort" + ViewModeList = "list" + ViewModeForm = "form" + ViewModeKanban = "kanban" + ViewModePivot = "pivot" + ViewModeGraph = "graph" + ViewModeCalendar = "calendar" + ViewModeGantt = "gantt" + ViewModeMap = "map" + ViewModeCohort = "cohort" ViewModeHierarchy = "hierarchy" ViewModeActivity = "activity" - ViewModeSearch = "search" - ViewModeIframe = "iframe" + ViewModeSearch = "search" + ViewModeIframe = "iframe" ) // WorkspaceQuery is the /web workspace query (empty fields are omitted). diff --git a/core/engine/swcmeta/arch.go b/core/engine/swcmeta/arch.go index ebfcb459..2c17ffd6 100644 --- a/core/engine/swcmeta/arch.go +++ b/core/engine/swcmeta/arch.go @@ -102,6 +102,7 @@ func SerializeViewForUser(ctx context.Context, view *parser.View) ViewArch { BulkModes: strings.Join(caps.BulkModes, ","), } } + translateArch(ctx, orm.UserLang(ctx), &arch) return arch } @@ -125,7 +126,7 @@ func formMetaForModel(model string) *FormMeta { } func enrichFields(model string, fields []ArchField) []ArchField { - if model == "" || len(fields) == 0 { + if model == "" || 0 == len(fields) { return fields } out := make([]ArchField, len(fields)) @@ -162,7 +163,7 @@ func enrichField(model string, f ArchField) ArchField { if f.Relation == "" { f.Relation = fd.Relation } - if len(f.Selection) == 0 && len(fd.Selection) > 0 { + if 0 == len(f.Selection) && len(fd.Selection) > 0 { f.Selection = fd.Selection } if fd.Required { @@ -315,7 +316,7 @@ func parseArchInt(raw string) int { } func serializeDivs(ctx context.Context, model string, s *parser.Sheet) []ArchDiv { - if s == nil || len(s.Div) == 0 { + if s == nil || 0 == len(s.Div) { return nil } out := make([]ArchDiv, 0, len(s.Div)) @@ -325,7 +326,7 @@ func serializeDivs(ctx context.Context, model string, s *parser.Sheet) []ArchDiv out = append(out, div) } } - if len(out) == 0 { + if 0 == len(out) { return nil } return out @@ -440,14 +441,14 @@ func parseFieldOptions(raw string) map[string]string { out[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) } } - if len(out) == 0 { + if 0 == len(out) { return nil } return out } func serializeSeparators(items []parser.Separator) []ArchSeparator { - if len(items) == 0 { + if 0 == len(items) { return nil } out := make([]ArchSeparator, 0, len(items)) @@ -457,8 +458,126 @@ func serializeSeparators(items []parser.Separator) []ArchSeparator { return out } +func translateArch(ctx context.Context, lang string, arch *ViewArch) { + if arch == nil || lang == "" || lang == "en_US" { + return + } + arch.Title = orm.Translate(ctx, lang, arch.Title) + arch.Fields = translateArchFields(ctx, lang, arch.Fields) + if arch.Header != nil { + arch.Header.Buttons = translateButtons(ctx, lang, arch.Header.Buttons) + arch.Header.Fields = translateArchFields(ctx, lang, arch.Header.Fields) + } + if arch.Footer != nil { + arch.Footer.Buttons = translateButtons(ctx, lang, arch.Footer.Buttons) + } + if arch.Sheet != nil { + translateSheet(ctx, lang, arch.Sheet) + } + translateSearch(ctx, lang, arch.Search) +} + +func translateSheet(ctx context.Context, lang string, sheet *ArchSheet) { + sheet.Fields = translateArchFields(ctx, lang, sheet.Fields) + sheet.Groups = translateGroups(ctx, lang, sheet.Groups) + sheet.Divs = translateDivs(ctx, lang, sheet.Divs) + sheet.Separators = translateSeparators(ctx, lang, sheet.Separators) + sheet.Labels = translateLabels(ctx, lang, sheet.Labels) + for i := range sheet.Notebook { + for j := range sheet.Notebook[i].Pages { + page := &sheet.Notebook[i].Pages[j] + page.Title = orm.Translate(ctx, lang, page.Title) + page.Fields = translateArchFields(ctx, lang, page.Fields) + page.Groups = translateGroups(ctx, lang, page.Groups) + page.Separators = translateSeparators(ctx, lang, page.Separators) + page.Labels = translateLabels(ctx, lang, page.Labels) + } + } +} + +func translateGroups(ctx context.Context, lang string, groups []ArchGroup) []ArchGroup { + for i := range groups { + groups[i].String = orm.Translate(ctx, lang, groups[i].String) + groups[i].Fields = translateArchFields(ctx, lang, groups[i].Fields) + groups[i].Groups = translateGroups(ctx, lang, groups[i].Groups) + groups[i].Separators = translateSeparators(ctx, lang, groups[i].Separators) + groups[i].Labels = translateLabels(ctx, lang, groups[i].Labels) + } + return groups +} + +func translateDivs(ctx context.Context, lang string, divs []ArchDiv) []ArchDiv { + for i := range divs { + divs[i].Fields = translateArchFields(ctx, lang, divs[i].Fields) + divs[i].H1Fields = translateArchFields(ctx, lang, divs[i].H1Fields) + divs[i].Buttons = translateButtons(ctx, lang, divs[i].Buttons) + divs[i].Divs = translateDivs(ctx, lang, divs[i].Divs) + } + return divs +} + +func translateArchFields(ctx context.Context, lang string, fields []ArchField) []ArchField { + for i := range fields { + fields[i].String = orm.Translate(ctx, lang, fields[i].String) + fields[i].Placeholder = orm.Translate(ctx, lang, fields[i].Placeholder) + fields[i].Selection = translateSelection(ctx, lang, fields[i].Selection) + if fields[i].Subview != nil { + fields[i].Subview.Fields = translateArchFields(ctx, lang, fields[i].Subview.Fields) + } + } + return fields +} + +func translateButtons(ctx context.Context, lang string, buttons []ArchButton) []ArchButton { + for i := range buttons { + buttons[i].String = orm.Translate(ctx, lang, buttons[i].String) + } + return buttons +} + +func translateSeparators(ctx context.Context, lang string, items []ArchSeparator) []ArchSeparator { + for i := range items { + items[i].String = orm.Translate(ctx, lang, items[i].String) + } + return items +} + +func translateLabels(ctx context.Context, lang string, items []ArchLabel) []ArchLabel { + for i := range items { + items[i].String = orm.Translate(ctx, lang, items[i].String) + } + return items +} + +func translateSelection(ctx context.Context, lang string, selection [][]string) [][]string { + if 0 == len(selection) { + return selection + } + out := make([][]string, len(selection)) + for i, pair := range selection { + cp := append([]string(nil), pair...) + if len(cp) >= 2 { + cp[1] = orm.Translate(ctx, lang, cp[1]) + } + out[i] = cp + } + return out +} + +func translateSearch(ctx context.Context, lang string, meta *SearchMeta) { + if meta == nil || lang == "" || lang == "en_US" { + return + } + for i := range meta.Filters { + meta.Filters[i].String = orm.Translate(ctx, lang, meta.Filters[i].String) + } + meta.SearchFields = translateArchFields(ctx, lang, meta.SearchFields) + meta.FilterFields = translateArchFields(ctx, lang, meta.FilterFields) + meta.GroupByFields = translateArchFields(ctx, lang, meta.GroupByFields) +} + func serializeLabels(items []parser.Label) []ArchLabel { - if len(items) == 0 { + if 0 == len(items) { return nil } out := make([]ArchLabel, 0, len(items)) diff --git a/core/engine/swcmeta/kanban.go b/core/engine/swcmeta/kanban.go index 0caf90cc..d41c9884 100644 --- a/core/engine/swcmeta/kanban.go +++ b/core/engine/swcmeta/kanban.go @@ -51,9 +51,13 @@ func BuildKanbanColumns(ctx context.Context, view *parser.View, rows []map[strin } cols, err := defaultMany2OneKanbanColumns(ctx, view.Model, groupField, rows) - if err != nil || len(cols) == 0 { + if err != nil { + return nil, groupField, view.KanbanDraggable() + } + if 0 == len(cols) { return nil, groupField, view.KanbanDraggable() } + return cols, groupField, view.KanbanDraggable() } @@ -73,7 +77,7 @@ func defaultMany2OneKanbanColumns(ctx context.Context, model, groupField string, index := map[int64]int{} for _, cr := range comodelRows { id, ok := orm.CoerceInt64(cr["id"]) - if !ok || id <= 0 { + if !ok || 0 >= id { continue } fold := asBoolRow(cr["fold"]) @@ -92,7 +96,7 @@ func defaultMany2OneKanbanColumns(ctx context.Context, model, groupField string, for _, row := range rows { gv, _ := orm.CoerceInt64(row[groupField]) - if gv <= 0 { + if 0 >= gv { continue } if ix, ok := index[gv]; ok { @@ -110,7 +114,7 @@ func defaultMany2OneKanbanColumns(ctx context.Context, model, groupField string, var unassigned []map[string]interface{} for _, row := range rows { gv, _ := orm.CoerceInt64(row[groupField]) - if gv <= 0 { + if 0 >= gv { unassigned = append(unassigned, row) } } diff --git a/core/engine/swcmeta/m2o_names.go b/core/engine/swcmeta/m2o_names.go index a97baa7e..3f265737 100644 --- a/core/engine/swcmeta/m2o_names.go +++ b/core/engine/swcmeta/m2o_names.go @@ -12,7 +12,7 @@ import ( // instead of "#". It batch-resolves each comodel once. func enrichMany2OneNames(ctx context.Context, model string, rows []map[string]interface{}) { inst, ok := orm.Registry[model] - if !ok || inst == nil || len(rows) == 0 { + if !ok || inst == nil || 0 == len(rows) { return } for _, fd := range inst.Fields() { @@ -22,7 +22,7 @@ func enrichMany2OneNames(ctx context.Context, model string, rows []map[string]in fieldName := fd.Name comodel := strings.TrimSpace(fd.Relation) ids := collectDistinctIDs(rows, fieldName) - if len(ids) == 0 { + if 0 == len(ids) { continue } values := make([]interface{}, 0, len(ids)) diff --git a/core/engine/swcmeta/pivot.go b/core/engine/swcmeta/pivot.go index c94923ec..f3605601 100644 --- a/core/engine/swcmeta/pivot.go +++ b/core/engine/swcmeta/pivot.go @@ -12,7 +12,7 @@ import ( // BuildPivotData aggregates list rows for a pivot view definition. func BuildPivotData(view *parser.View, rows []map[string]interface{}) *PivotMeta { - if view == nil || len(view.Field) == 0 { + if view == nil || 0 == len(view.Field) { return nil } var rowFields, colFields, measureFields []parser.Field @@ -26,7 +26,7 @@ func BuildPivotData(view *parser.View, rows []map[string]interface{}) *PivotMeta measureFields = append(measureFields, f) } } - if len(measureFields) == 0 { + if 0 == len(measureFields) { return nil } measure := measureFields[0] @@ -56,7 +56,7 @@ func BuildPivotData(view *parser.View, rows []map[string]interface{}) *PivotMeta Values: values, MeasureLabel: measureLabel, } - if len(out.RowLabels) == 0 { + if 0 == len(out.RowLabels) { out.RowLabels = []string{"Total"} } return out @@ -70,7 +70,7 @@ func fieldDisplayLabel(f parser.Field) string { } func pivotKey(row map[string]interface{}, fields []parser.Field) string { - if len(fields) == 0 { + if 0 == len(fields) { return "Total" } parts := make([]string, 0, len(fields)) diff --git a/core/engine/swcmeta/report_payload.go b/core/engine/swcmeta/report_payload.go index 52e26553..d368fe88 100644 --- a/core/engine/swcmeta/report_payload.go +++ b/core/engine/swcmeta/report_payload.go @@ -11,14 +11,14 @@ import ( // Master-data models always expose Reports import/export in workspace chrome. var masterDataReportModels = map[string]struct{}{ - "core.user": {}, - "core.company": {}, - "core.group": {}, - "core.lang": {}, - "core.country": {}, + "core.user": {}, + "core.company": {}, + "core.group": {}, + "core.lang": {}, + "core.country": {}, "core.country.state": {}, - "core.city": {}, - "core.user.apikey": {}, + "core.city": {}, + "core.user.apikey": {}, } // ApplyReportToArch sets arch.Report from the active view, sibling action views, or master-data defaults. @@ -48,6 +48,20 @@ func ApplyReportToArch( } } +// ExportEntitlementsForModel merges report download/upload capabilities from action view modes, +// including master-data defaults used in workspace chrome. +func ExportEntitlementsForModel(ctx context.Context, model string, actionViewModes []string) report.Capabilities { + caps := mergeReportFromSiblingViews(ctx, model, actionViewModes) + if caps.HasDownload() || caps.BulkUpload { + return caps + } + if _, ok := masterDataReportModels[strings.TrimSpace(model)]; ok { + mergeDefaultMasterDataCaps(&caps) + normalizeReportCapabilities(&caps) + } + return caps +} + func mergeReportFromSiblingViews(ctx context.Context, model string, modes []string) report.Capabilities { model = strings.TrimSpace(model) if model == "" { @@ -109,10 +123,10 @@ func appendUniqueStrings(list []string, items ...string) []string { } func normalizeReportCapabilities(caps *report.Capabilities) { - if caps.BulkUpload && len(caps.BulkModes) == 0 { + if caps.BulkUpload && 0 == len(caps.BulkModes) { caps.BulkModes = []string{report.ImportModeCreate, report.ImportModeUpsert} } - if caps.HasDownload() && len(caps.PDFSizes) == 0 { + if caps.HasDownload() && 0 == len(caps.PDFSizes) { caps.PDFSizes = []string{report.PageSizeA4, report.PageSizeLegal, report.PageSizeLetter} } } diff --git a/core/engine/swcmeta/saved_search.go b/core/engine/swcmeta/saved_search.go index b72de163..d2b57d6b 100644 --- a/core/engine/swcmeta/saved_search.go +++ b/core/engine/swcmeta/saved_search.go @@ -15,7 +15,7 @@ func LoadSavedSearches(ctx context.Context, actionID int, model string) []SavedS return nil } uid := orm.UIDFromContext(ctx) - if uid <= 0 { + if 0 >= uid { return nil } seen := map[int]struct{}{} @@ -30,7 +30,7 @@ func LoadSavedSearches(ctx context.Context, actionID int, model string) []SavedS } for _, row := range rows { id, _ := orm.CoerceInt64(row["id"]) - if id <= 0 { + if 0 >= id { continue } if _, ok := seen[int(id)]; ok { @@ -81,31 +81,31 @@ func SaveSavedSearch(ctx context.Context, in SavedSearchInput) (int, error) { return 0, fmt.Errorf("model and name required") } uid := orm.UIDFromContext(ctx) - if uid <= 0 { + if 0 >= uid { return 0, fmt.Errorf("login required") } vals := map[string]interface{}{ - "name": name, - "user_id": int64(uid), - "model": model, - "action_id": int64(in.ActionID), - "search_query": strings.TrimSpace(in.Search), - "filter_csv": strings.TrimSpace(in.Filter), - "domain_json": strings.TrimSpace(in.Domain), - "group_by": strings.TrimSpace(in.GroupBy), - "is_default": in.IsDefault, - "is_shared": in.IsShared, + "name": name, + "user_id": int64(uid), + "model": model, + "action_id": int64(in.ActionID), + "search_query": strings.TrimSpace(in.Search), + "filter_csv": strings.TrimSpace(in.Filter), + "domain_json": strings.TrimSpace(in.Domain), + "group_by": strings.TrimSpace(in.GroupBy), + "is_default": in.IsDefault, + "is_shared": in.IsShared, } return orm.Create(ctx, orm.RegistryModel("swc.saved.search"), vals) } // DeleteSavedSearch removes a saved search owned by the current user. func DeleteSavedSearch(ctx context.Context, id int) error { - if id <= 0 { + if 0 >= id { return fmt.Errorf("invalid id") } uid := orm.UIDFromContext(ctx) - if uid <= 0 { + if 0 >= uid { return fmt.Errorf("login required") } row, err := orm.SearchOne(ctx, "swc.saved.search", map[string]interface{}{"id": id}) diff --git a/core/engine/swcmeta/search_meta.go b/core/engine/swcmeta/search_meta.go index ff37e236..3de8f15a 100644 --- a/core/engine/swcmeta/search_meta.go +++ b/core/engine/swcmeta/search_meta.go @@ -86,7 +86,7 @@ func GroupByFields(model string) []ArchField { } func searchFieldsFromView(ctx context.Context, model string, view *parser.View) []ArchField { - if view == nil || len(view.Field) == 0 { + if view == nil || 0 == len(view.Field) { return nil } fields := serializeFields(ctx, view.Field) @@ -108,14 +108,15 @@ func BuildSearchMeta(ctx context.Context, model string, searchView *parser.View) meta.SearchFields = sf } } - if len(meta.Filters) == 0 && len(meta.FilterFields) == 0 && len(meta.GroupByFields) == 0 && len(meta.SearchFields) == 0 { + if 0 == len(meta.Filters) && 0 == len(meta.FilterFields) && 0 == len(meta.GroupByFields) && 0 == len(meta.SearchFields) { return nil } + translateSearch(ctx, orm.UserLang(ctx), meta) return meta } func serializeSearchFilters(view *parser.View) []SearchFilterMeta { - if view == nil || len(view.SearchFilter) == 0 { + if view == nil || 0 == len(view.SearchFilter) { return nil } out := make([]SearchFilterMeta, 0, len(view.SearchFilter)) diff --git a/core/engine/templates/auth/setup.html b/core/engine/templates/auth/setup.html index b8e51558..078fa3d0 100644 --- a/core/engine/templates/auth/setup.html +++ b/core/engine/templates/auth/setup.html @@ -15,6 +15,7 @@ Welcome + You are about to create your Sumeru workspace. This uses database {{.DbName}} from your configuration file. You will define your company, language, and the first administrator — there is no built-in default login. @@ -116,6 +117,7 @@ Welcome const password = document.getElementById('password'); const password2 = document.getElementById('password2'); const setupToken = document.getElementById('setup_token'); + const setupCSRF = document.getElementById('setup_csrf'); const passwordMatchHint = document.querySelector('[data-password-match-hint]'); const revCompany = document.getElementById('rev_company'); @@ -221,7 +223,8 @@ Welcome lang: lang.value, admin_name: adminName.value.trim(), email: email.value.trim(), - password: password.value + password: password.value, + csrf_token: setupCSRF ? setupCSRF.value : '' }; if (setupToken) { payload.setup_token = setupToken.value; @@ -229,6 +232,9 @@ Welcome try { var headers = { 'Content-Type': 'application/json' }; + if (setupCSRF && setupCSRF.value) { + headers['X-CSRF-Token'] = setupCSRF.value; + } if (setupToken && setupToken.value) { headers['X-Setup-Token'] = setupToken.value; } diff --git a/core/engine/templates/pages/settings_auth.html b/core/engine/templates/pages/settings_auth.html new file mode 100644 index 00000000..07de6196 --- /dev/null +++ b/core/engine/templates/pages/settings_auth.html @@ -0,0 +1,24 @@ + + + + Authentication + Local login and password policy (stored in system parameters). + + Back to Settings + + + + + + + + Local password login + + Minimum password length + + + + Save + + + 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 @@ + + + + Mail delivery + Outbound SMTP is configured in the server config file (sumeru.conf), not in the database. + + Back to Settings + + + + SMTP + {{if .Configured}} + Mail sending is enabled with the settings below. + {{else}} + Mail sending is not configured. Set smtp_host and smtp_from in the server config. + {{end}} + + Host{{if .SMTPHost}}{{.SMTPHost}}{{else}}—{{end}} + Port{{.SMTPPort}} + From{{if .SMTPFrom}}{{.SMTPFrom}}{{else}}—{{end}} + Username{{if .SMTPUser}}{{.SMTPUser}}{{else}}—{{end}} + + + 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, "
- Create your first {{.HumanTitle}}! + Create your first {{.DisplayName}}!
You are about to create your Sumeru workspace. This uses database {{.DbName}} from your configuration file. You will define your company, language, and the first administrator — there is no built-in default login.
Local login and password policy (stored in system parameters).
Outbound SMTP is configured in the server config file (sumeru.conf), not in the database.
sumeru.conf
Mail sending is enabled with the settings below.
Mail sending is not configured. Set smtp_host and smtp_from in the server config.
smtp_host
smtp_from
{{.Record.name}}