diff --git a/CHANGELOG.md b/CHANGELOG.md index 425f4e3..a78a728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to MageBox will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.2.0] - 2026-09-28 + +### Changed + +- **`MAGE_RUN_CODE` Is Only Sent When a Domain Names a Store** - MageBox injected `MAGE_RUN_CODE` and `MAGE_RUN_TYPE` into every vhost, so Magento looked for a store with that exact code even in single-store projects whose default code is not `default`, instead of resolving the store itself. A domain now contributes those variables only when it sets `store_code`, and one project can mix store and website codes. Thanks to Louis de Looze. ([#109](https://github.com/qoliber/magebox/pull/109), [#107](https://github.com/qoliber/magebox/issues/107)) +- **`mage_run_code` and `mage_run_type` Renamed to `store_code` and `store_type`** - The old names still work and print a deprecation warning on `magebox start`, because the config loader ignores unknown keys and a project written for an older release would otherwise lose its store codes silently and serve the wrong store. + +### Added + +- **`--store-type` on `magebox domain add`** - Declares whether a code names a store view or a website. + +### Fixed + +- **A Second Multistore Project Would Have Stopped nginx Entirely** - The store map was written per project into the shared vhosts directory, and nginx rejects a second `map` for the same variable with `duplicate "MAGE_RUN_CODE" variable`, which stops it serving every project on the machine. All projects now share one map file, rebuilt from every project on `magebox start`, and the per-project files an earlier build left behind are removed. +- **Store Codes Leaked into Subdomains** - The map matched with a leading dot in nginx's `hostnames` mode, so a deliberately code-less `admin.mystore.test` inherited the code of `mystore.test`. Hosts are now matched exactly. +- **An Invalid `store_type` Passed Silently** - Anything other than `store` or `website` was sent to Magento as `MAGE_RUN_TYPE`. A typo now fails validation. + ## [2.1.2] - 2026-09-24 ### Fixed diff --git a/VERSION b/VERSION index eca07e4..ccbccc3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.2 +2.2.0 diff --git a/cmd/magebox/domain.go b/cmd/magebox/domain.go index 5f4b687..5da1dd1 100644 --- a/cmd/magebox/domain.go +++ b/cmd/magebox/domain.go @@ -53,12 +53,14 @@ var domainListCmd = &cobra.Command{ var ( domainStoreCode string + domainStoreType string domainRoot string domainSSL bool ) func init() { - domainAddCmd.Flags().StringVar(&domainStoreCode, "store-code", "", "Magento store code (default: \"default\")") + domainAddCmd.Flags().StringVar(&domainStoreCode, "store-code", "", "Magento store/website code for multi-store setup") + domainAddCmd.Flags().StringVar(&domainStoreType, "store-type", "", "Magento run type: \"store\" or \"website\" (default: \"store\")") domainAddCmd.Flags().StringVar(&domainRoot, "root", "", "Document root relative to project (default: \"pub\" for Magento, \"public\" for Laravel)") domainAddCmd.Flags().BoolVar(&domainSSL, "ssl", true, "Enable SSL for the domain") @@ -92,9 +94,10 @@ func runDomainAdd(cmd *cobra.Command, args []string) error { // Create new domain newDomain := config.Domain{ - Host: host, - Root: domainRoot, - MageRunCode: domainStoreCode, + Host: host, + Root: domainRoot, + StoreCode: domainStoreCode, + StoreType: domainStoreType, } // Only set SSL if explicitly changed from default (true) @@ -163,7 +166,9 @@ func runDomainAdd(cmd *cobra.Command, args []string) error { } fmt.Println() - cli.PrintInfo("Domain %s configured with store code: %s", host, newDomain.GetStoreCode()) + if newDomain.StoreCode != "" { + cli.PrintInfo("Domain %s configured with store code: %s (%s)", host, newDomain.StoreCode, newDomain.GetStoreType()) + } return nil } @@ -284,7 +289,9 @@ func runDomainList(cmd *cobra.Command, args []string) error { fmt.Printf(" %s\n", cli.Highlight(d.Host)) fmt.Printf(" URL: %s://%s\n", protocol, d.Host) fmt.Printf(" Root: %s\n", d.GetRoot()) - fmt.Printf(" Store Code: %s\n", d.GetStoreCode()) + if d.StoreCode != "" { + fmt.Printf(" Store Code: %s (%s)\n", d.StoreCode, d.GetStoreType()) + } fmt.Printf(" SSL: %s\n", sslStatus) fmt.Println() } diff --git a/internal/config/loader.go b/internal/config/loader.go index c0f26df..030fd4b 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -99,6 +99,10 @@ func (l *Loader) loadFileWithIncludes(path string, visited map[string]bool) (*Co return nil, &ParseError{Path: path, Err: err} } + // Settings renamed in 2.3.0 are migrated here rather than at each use, so a + // project written for an older release cannot silently lose them. + config.DeprecationWarnings = config.MigrateDeprecatedKeys() + if len(config.IncludeConfig) == 0 { return &config, nil } diff --git a/internal/config/store_test.go b/internal/config/store_test.go new file mode 100644 index 0000000..2391ceb --- /dev/null +++ b/internal/config/store_test.go @@ -0,0 +1,65 @@ +package config + +import "testing" + +// A store type other than store or website silently produces a MAGE_RUN_TYPE +// Magento does not understand, so a typo must fail validation. +func TestConfigValidatesStoreType(t *testing.T) { + base := func(storeType string) *Config { + return &Config{ + Name: "mystore", + PHP: "8.3", + Domains: []Domain{{Host: "mystore.test", StoreCode: "nl", StoreType: storeType}}, + } + } + + tests := []struct { + name string + storeType string + wantError bool + }{ + {name: "unset defaults to store", storeType: "", wantError: false}, + {name: "store", storeType: "store", wantError: false}, + {name: "website", storeType: "website", wantError: false}, + {name: "typo", storeType: "websites", wantError: true}, + {name: "store view", storeType: "store_view", wantError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := base(tt.storeType).Validate() + if (err != nil) != tt.wantError { + t.Errorf("Validate() error = %v, wantError %v", err, tt.wantError) + } + }) + } +} + +// Projects written for earlier MageBox releases use mage_run_code and +// mage_run_type. The loader ignores unknown keys, so without a migration those +// projects would quietly lose their store codes and serve the wrong store. +func TestMigrateDeprecatedStoreKeys(t *testing.T) { + cfg := &Config{ + Name: "mystore", + Domains: []Domain{ + {Host: "mystore.test", MageRunCode: "nl", MageRunType: "website"}, + {Host: "de.mystore.test", StoreCode: "de", MageRunCode: "ignored"}, + {Host: "plain.test"}, + }, + } + + warnings := cfg.MigrateDeprecatedKeys() + + if cfg.Domains[0].StoreCode != "nl" || cfg.Domains[0].StoreType != "website" { + t.Errorf("legacy keys were not migrated: %+v", cfg.Domains[0]) + } + if cfg.Domains[1].StoreCode != "de" { + t.Errorf("an explicit store_code must win over the legacy key: %+v", cfg.Domains[1]) + } + if len(warnings) == 0 { + t.Error("migrating a legacy key should report a deprecation warning") + } + if cfg.Domains[2].StoreCode != "" { + t.Errorf("a domain without either key must stay empty: %+v", cfg.Domains[2]) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index d0c071a..3baf902 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -29,6 +29,10 @@ type Config struct { ComposeFile string `yaml:"compose_file,omitempty"` // Path to project-specific docker-compose.yml Sandbox *SandboxConfig `yaml:"sandbox,omitempty"` IncludeConfig []string `yaml:"include_config,omitempty"` // Paths to additional config files or directories to merge + + // DeprecationWarnings are filled by the loader for settings that still work + // under an old name. Not part of the file itself. + DeprecationWarnings []string `yaml:"-"` } // GetType returns the project type, defaulting to "magento" @@ -174,11 +178,18 @@ func (c *Command) UnmarshalYAML(unmarshal func(interface{}) error) error { // Domain represents a domain configuration type Domain struct { - Host string `yaml:"host"` - Root string `yaml:"root,omitempty"` - SSL *bool `yaml:"ssl,omitempty"` - MageRunCode string `yaml:"mage_run_code,omitempty"` // Magento store/website code for multi-store setup - MageRunType string `yaml:"mage_run_type,omitempty"` // "store" or "website" (default: "store") + Host string `yaml:"host"` + Root string `yaml:"root,omitempty"` + SSL *bool `yaml:"ssl,omitempty"` + StoreCode string `yaml:"store_code,omitempty"` // Magento store/website code for multi-store setup + StoreType string `yaml:"store_type,omitempty"` // "store" or "website" (default: "store") + + // MageRunCode and MageRunType are the names these settings had before 2.3.0. + // They are still read, because the loader ignores unknown keys and a project + // written for an older release would otherwise lose its store code without + // any warning and serve the wrong store. + MageRunCode string `yaml:"mage_run_code,omitempty"` + MageRunType string `yaml:"mage_run_type,omitempty"` } // Services represents the services configuration @@ -321,20 +332,22 @@ func (d *Domain) IsSSLEnabled() bool { return *d.SSL } -// GetStoreCode returns the Magento store code, defaulting to "default" -func (d *Domain) GetStoreCode() string { - if d.MageRunCode == "" { - return "default" +// GetStoreType returns the Magento run type, defaulting to "store" +func (d *Domain) GetStoreType() string { + if d.StoreType == "" { + return "store" } - return d.MageRunCode + return d.StoreType } -// GetMageRunType returns the Magento run type, defaulting to "store" -func (d *Domain) GetMageRunType() string { - if d.MageRunType == "" { - return "store" +// HasMultiStore returns true if any domain has a store code configured +func (c *Config) HasMultiStore() bool { + for _, d := range c.Domains { + if d.StoreCode != "" { + return true + } } - return d.MageRunType + return false } // Validate checks if the configuration is valid @@ -353,6 +366,15 @@ func (c *Config) Validate() error { if c.PHP == "" { return &ValidationError{Field: "php", Message: "php version is required"} } + for i, d := range c.Domains { + if !validStoreTypes[d.StoreType] { + return &ValidationError{ + Field: "domains", + Message: fmt.Sprintf("store_type %q is not valid, expected \"store\" or \"website\"", d.StoreType), + Index: i, + } + } + } return nil } @@ -468,3 +490,25 @@ func (s *Services) GetSearchService() *ServiceConfig { } return nil } + +// validStoreTypes are the values Magento understands for MAGE_RUN_TYPE. +var validStoreTypes = map[string]bool{"": true, "store": true, "website": true} + +// MigrateDeprecatedKeys copies settings from the names they had before 2.3.0 and +// returns a warning for each one found, so a project keeps working while its +// owner is told what to rename. +func (c *Config) MigrateDeprecatedKeys() []string { + var warnings []string + for i := range c.Domains { + domain := &c.Domains[i] + if domain.MageRunCode != "" && domain.StoreCode == "" { + domain.StoreCode = domain.MageRunCode + warnings = append(warnings, fmt.Sprintf("domain %s: 'mage_run_code' is deprecated, rename it to 'store_code'", domain.Host)) + } + if domain.MageRunType != "" && domain.StoreType == "" { + domain.StoreType = domain.MageRunType + warnings = append(warnings, fmt.Sprintf("domain %s: 'mage_run_type' is deprecated, rename it to 'store_type'", domain.Host)) + } + } + return warnings +} diff --git a/internal/lib/templates.go b/internal/lib/templates.go index e357883..d665cc6 100644 --- a/internal/lib/templates.go +++ b/internal/lib/templates.go @@ -100,6 +100,7 @@ var TemplateNames = map[string][]string{ "vhost.conf.tmpl", "proxy.conf.tmpl", "upstream.conf.tmpl", + "map.conf.tmpl", }, TemplatePHP: { "pool.conf.tmpl", diff --git a/internal/nginx/storemap.go b/internal/nginx/storemap.go new file mode 100644 index 0000000..0d760d8 --- /dev/null +++ b/internal/nginx/storemap.go @@ -0,0 +1,139 @@ +package nginx + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "text/template" + + "qoliber/magebox/internal/config" +) + +// StoreMapFile holds the host to store mapping for every project. +// +// nginx accepts one map per variable, and every file in the vhosts directory is +// included into the same http block. A map per project therefore makes nginx +// reject the whole configuration with `duplicate "MAGE_RUN_CODE" variable`, +// taking down every project on the machine, so all projects share this file. +// The numeric prefix keeps it ahead of the vhosts that read the variables. +const StoreMapFile = "000-magebox-store-map.conf" + +// storeMapTemplate renders the shared map. Hosts are matched exactly: nginx's +// `hostnames` mode with a leading dot would also match every subdomain, so a +// deliberately code-less admin host would inherit its parent's store code. +const storeMapTemplate = `# MageBox store map for every project with store codes +# Do not edit manually - regenerated on magebox start + +map $host $MAGE_RUN_CODE { + default ""; +{{- range .}} + {{.Host}} {{.Code}}; +{{- end}} +} + +map $host $MAGE_RUN_TYPE { + default ""; +{{- range .}} + {{.Host}} {{.Type}}; +{{- end}} +} +` + +// StoreMapEntry is one host in the shared map. +type StoreMapEntry struct { + Host string + Code string + Type string +} + +// StoreMapEntries collects the mapping from every project, sorted by host so +// the generated file does not churn between runs. +func StoreMapEntries(projects []*config.Config) []StoreMapEntry { + var entries []StoreMapEntry + for _, project := range projects { + if project == nil { + continue + } + for _, domain := range project.Domains { + if domain.StoreCode == "" { + continue + } + entries = append(entries, StoreMapEntry{ + Host: domain.Host, + Code: domain.StoreCode, + Type: domain.GetStoreType(), + }) + } + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Host < entries[j].Host }) + return entries +} + +// RenderStoreMap renders the shared map file. +func RenderStoreMap(entries []StoreMapEntry) (string, error) { + tmpl, err := template.New("storemap").Parse(storeMapTemplate) + if err != nil { + return "", fmt.Errorf("failed to parse the store map template: %w", err) + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, entries); err != nil { + return "", fmt.Errorf("failed to render the store map: %w", err) + } + return buf.String(), nil +} + +// WriteStoreMap installs the shared map, or removes it when no project uses +// store codes, and clears the per-project map files older releases wrote. +func WriteStoreMap(vhostsDir string, entries []StoreMapEntry) error { + if err := removeLegacyStoreMaps(vhostsDir); err != nil { + return err + } + + path := filepath.Join(vhostsDir, StoreMapFile) + if len(entries) == 0 { + // No vhost references the variables, and an empty map left behind would + // only be one more file to explain. + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove %s: %w", path, err) + } + return nil + } + + content, err := RenderStoreMap(entries) + if err != nil { + return err + } + if err := os.MkdirAll(vhostsDir, 0755); err != nil { + return fmt.Errorf("failed to create %s: %w", vhostsDir, err) + } + return os.WriteFile(path, []byte(content), 0644) +} + +// removeLegacyStoreMaps deletes the -map.conf files written before the +// map was shared. Left in place they reintroduce the duplicate variable. +func removeLegacyStoreMaps(vhostsDir string) error { + matches, err := filepath.Glob(filepath.Join(vhostsDir, "*-map.conf")) + if err != nil { + return err + } + for _, match := range matches { + if strings.HasSuffix(match, StoreMapFile) { + continue + } + if err := os.Remove(match); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove the legacy map %s: %w", match, err) + } + } + return nil +} + +// EnsureStoreMap rebuilds the shared map from every project MageBox knows about. +// +// It takes all projects rather than one, because a single file has to describe +// them all: nginx permits one map per variable. +func (g *VhostGenerator) EnsureStoreMap(projects []*config.Config) error { + return WriteStoreMap(g.vhostsDir, StoreMapEntries(projects)) +} diff --git a/internal/nginx/storemap_test.go b/internal/nginx/storemap_test.go new file mode 100644 index 0000000..b57367b --- /dev/null +++ b/internal/nginx/storemap_test.go @@ -0,0 +1,124 @@ +package nginx + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "qoliber/magebox/internal/config" +) + +func project(name string, domains ...config.Domain) *config.Config { + return &config.Config{Name: name, Domains: domains} +} + +// nginx allows one map per variable. A map block per project puts several in the +// shared vhosts directory, which nginx rejects with `duplicate "MAGE_RUN_CODE" +// variable`, and then no project on the machine serves at all. +func TestRenderStoreMapHasOneMapPerVariableForManyProjects(t *testing.T) { + projects := []*config.Config{ + project("shop", config.Domain{Host: "shop.test", StoreCode: "nl"}, config.Domain{Host: "de.shop.test", StoreCode: "de", StoreType: "website"}), + project("blog", config.Domain{Host: "blog.test", StoreCode: "blog"}), + project("plain", config.Domain{Host: "plain.test"}), + } + + content, err := RenderStoreMap(StoreMapEntries(projects)) + if err != nil { + t.Fatalf("RenderStoreMap failed: %v", err) + } + + if got := strings.Count(content, "map $host $MAGE_RUN_CODE"); got != 1 { + t.Errorf("found %d MAGE_RUN_CODE maps, want exactly 1:\n%s", got, content) + } + if got := strings.Count(content, "map $host $MAGE_RUN_TYPE"); got != 1 { + t.Errorf("found %d MAGE_RUN_TYPE maps, want exactly 1:\n%s", got, content) + } + for _, want := range []string{"shop.test", "de.shop.test", "blog.test"} { + if !strings.Contains(content, want) { + t.Errorf("map is missing host %q:\n%s", want, content) + } + } + if strings.Contains(content, "plain.test") { + t.Errorf("a domain without a store code must not appear:\n%s", content) + } +} + +// A leading dot with `hostnames` matches every subdomain, so a deliberately +// code-less admin subdomain would inherit its parent's store code. +func TestRenderStoreMapMatchesExactHostsOnly(t *testing.T) { + content, err := RenderStoreMap(StoreMapEntries([]*config.Config{ + project("shop", config.Domain{Host: "shop.test", StoreCode: "nl"}), + })) + if err != nil { + t.Fatalf("RenderStoreMap failed: %v", err) + } + + if strings.Contains(content, "hostnames") { + t.Errorf("the map must not use wildcard hostname matching:\n%s", content) + } + if strings.Contains(content, ".shop.test") { + t.Errorf("hosts must be exact, not dot-prefixed:\n%s", content) + } +} + +func TestStoreMapEntries(t *testing.T) { + entries := StoreMapEntries([]*config.Config{ + project("b", config.Domain{Host: "b.test", StoreCode: "b"}), + project("a", config.Domain{Host: "a.test", StoreCode: "a", StoreType: "website"}, config.Domain{Host: "nocode.test"}), + }) + + if len(entries) != 2 { + t.Fatalf("got %d entries, want 2: %+v", len(entries), entries) + } + // Sorted, so the generated file does not churn between runs. + if entries[0].Host != "a.test" || entries[1].Host != "b.test" { + t.Errorf("entries are not sorted by host: %+v", entries) + } + if entries[0].Type != "website" { + t.Errorf("explicit store type lost: %+v", entries[0]) + } + if entries[1].Type != "store" { + t.Errorf("missing store type should default to store: %+v", entries[1]) + } +} + +// Upgrading from the per-project map must not leave those files behind, or the +// duplicate variable they cause keeps nginx broken. +func TestEnsureStoreMapRemovesLegacyPerProjectFiles(t *testing.T) { + dir := t.TempDir() + legacy := filepath.Join(dir, "shop-map.conf") + if err := os.WriteFile(legacy, []byte("map $host $MAGE_RUN_CODE {}\n"), 0644); err != nil { + t.Fatal(err) + } + + if err := WriteStoreMap(dir, StoreMapEntries([]*config.Config{ + project("shop", config.Domain{Host: "shop.test", StoreCode: "nl"}), + })); err != nil { + t.Fatalf("WriteStoreMap failed: %v", err) + } + + if _, err := os.Stat(legacy); !os.IsNotExist(err) { + t.Error("the legacy per-project map file was not removed") + } + if _, err := os.Stat(filepath.Join(dir, StoreMapFile)); err != nil { + t.Errorf("the shared map file was not written: %v", err) + } +} + +// With no store codes anywhere the file must go, so no vhost references a +// variable nginx does not know. +func TestWriteStoreMapRemovesFileWhenNoStoreCodes(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, StoreMapFile) + if err := os.WriteFile(path, []byte("map $host $MAGE_RUN_CODE {}\n"), 0644); err != nil { + t.Fatal(err) + } + + if err := WriteStoreMap(dir, StoreMapEntries([]*config.Config{project("plain", config.Domain{Host: "plain.test"})})); err != nil { + t.Fatalf("WriteStoreMap failed: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("the shared map file should be removed when nothing uses store codes") + } +} diff --git a/internal/nginx/templates/vhost.conf.tmpl b/internal/nginx/templates/vhost.conf.tmpl index d4e494a..b83babf 100644 --- a/internal/nginx/templates/vhost.conf.tmpl +++ b/internal/nginx/templates/vhost.conf.tmpl @@ -106,8 +106,6 @@ server { set $MAGE_ROOT {{.DocumentRoot}}; set $MAGE_MODE developer; - set $MAGE_RUN_CODE {{.StoreCode}}; - set $MAGE_RUN_TYPE {{.MageRunType}}; root $MAGE_ROOT; index index.php; @@ -221,8 +219,10 @@ server { fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - fastcgi_param MAGE_RUN_CODE $MAGE_RUN_CODE; - fastcgi_param MAGE_RUN_TYPE $MAGE_RUN_TYPE; +{{- if .HasStoreCodes}} + fastcgi_param MAGE_RUN_CODE $MAGE_RUN_CODE if_not_empty; + fastcgi_param MAGE_RUN_TYPE $MAGE_RUN_TYPE if_not_empty; +{{- end}} include fastcgi_params; } diff --git a/internal/nginx/vhost.go b/internal/nginx/vhost.go index 8fd0592..02e6839 100644 --- a/internal/nginx/vhost.go +++ b/internal/nginx/vhost.go @@ -74,8 +74,7 @@ type VhostConfig struct { HTTPSPort int // 443 on Linux, 8443 on macOS (port forwarding) BackendPort int // Backend port for Varnish (always 8080 when Varnish enabled) EnableIPv6 bool // true on Linux to add [::]:port listen directives - StoreCode string // Magento store code for multi-store setup (default: "default") - MageRunType string // Magento run type: "store" or "website" (default: "store") + HasStoreCodes bool // True when any domain has a store_code; enables MAGE_RUN_* fastcgi params AccessLog string // Path to access log file ErrorLog string // Path to error log file CustomNginxDir string // Path to project-level custom nginx snippets directory (if it exists) @@ -132,6 +131,11 @@ func (g *VhostGenerator) Generate(cfg *config.Config, projectPath string) error return fmt.Errorf("failed to generate upstream config: %w", err) } + // The host map is shared by every project and written by the caller, which + // is the only place that knows all of them. One map per project would give + // nginx a duplicate variable and stop it serving anything. + hasStoreCodes := cfg.HasMultiStore() + // Determine ports based on platform // macOS uses port forwarding (80->8080, 443->8443), Linux uses standard ports httpPort := 80 @@ -167,8 +171,7 @@ func (g *VhostGenerator) Generate(cfg *config.Config, projectPath string) error HTTPSPort: httpsPort, BackendPort: backendPort, EnableIPv6: enableIPv6, - StoreCode: domain.GetStoreCode(), - MageRunType: domain.GetMageRunType(), + HasStoreCodes: hasStoreCodes, AccessLog: filepath.Join(logsDir, fmt.Sprintf("%s-access.log", sanitizedDomain)), ErrorLog: filepath.Join(logsDir, fmt.Sprintf("%s-error.log", sanitizedDomain)), } diff --git a/internal/nginx/vhost_test.go b/internal/nginx/vhost_test.go index 3f53032..3333252 100644 --- a/internal/nginx/vhost_test.go +++ b/internal/nginx/vhost_test.go @@ -283,6 +283,100 @@ func TestRenderVhost_SSLEnabled_NoIPv6(t *testing.T) { } } +func TestRenderVhost_NoStoreCodes(t *testing.T) { + g, tmpDir := setupTestGenerator(t) + + cfg := VhostConfig{ + ProjectName: "mystore", + Domain: "mystore.test", + DocumentRoot: "/var/www/mystore/pub", + PHPVersion: "8.2", + PHPSocketPath: filepath.Join(tmpDir, ".magebox", "run", "mystore-php8.2.sock"), + HasStoreCodes: false, + HTTPPort: 80, + HTTPSPort: 443, + } + + content, err := g.renderVhost(cfg) + if err != nil { + t.Fatalf("renderVhost failed: %v", err) + } + + if strings.Contains(content, "MAGE_RUN_CODE") { + t.Error("Vhost without store codes should not contain MAGE_RUN_CODE") + } + if strings.Contains(content, "MAGE_RUN_TYPE") { + t.Error("Vhost without store codes should not contain MAGE_RUN_TYPE") + } + if strings.Contains(content, "set $MAGE_RUN") { + t.Error("Vhost without store codes should not contain set $MAGE_RUN_* directives") + } +} + +func TestRenderVhost_WithStoreCodes(t *testing.T) { + g, tmpDir := setupTestGenerator(t) + + cfg := VhostConfig{ + ProjectName: "mystore", + Domain: "mystore.test", + DocumentRoot: "/var/www/mystore/pub", + PHPVersion: "8.2", + PHPSocketPath: filepath.Join(tmpDir, ".magebox", "run", "mystore-php8.2.sock"), + HasStoreCodes: true, + HTTPPort: 80, + HTTPSPort: 443, + } + + content, err := g.renderVhost(cfg) + if err != nil { + t.Fatalf("renderVhost failed: %v", err) + } + + checks := []string{ + "fastcgi_param MAGE_RUN_CODE $MAGE_RUN_CODE if_not_empty", + "fastcgi_param MAGE_RUN_TYPE $MAGE_RUN_TYPE if_not_empty", + } + for _, check := range checks { + if !strings.Contains(content, check) { + t.Errorf("Vhost with store codes should contain %q", check) + } + } + // map-based setup: no set directives in the server block + if strings.Contains(content, "set $MAGE_RUN_CODE") { + t.Error("Vhost should not use set $MAGE_RUN_CODE (handled by map block)") + } +} + +// The host map is shared by every project (see storemap_test.go). Generate must +// not write a per-project one, because nginx rejects a duplicate map variable +// and then serves no project at all. +func TestGenerateWritesNoPerProjectMap(t *testing.T) { + g, tmpDir := setupTestGenerator(t) + + cfg := &config.Config{ + Name: "mystore", + Domains: []config.Domain{ + {Host: "mystore.test", Root: "pub", StoreCode: "default"}, + {Host: "de.mystore.test", Root: "pub", StoreCode: "german"}, + }, + PHP: "8.2", + } + + if err := g.Generate(cfg, filepath.Join(tmpDir, "projects", "mystore")); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + matches, err := filepath.Glob(filepath.Join(g.vhostsDir, "*-map.conf")) + if err != nil { + t.Fatal(err) + } + for _, match := range matches { + if filepath.Base(match) != StoreMapFile { + t.Errorf("Generate wrote a per-project map file: %s", match) + } + } +} + func TestRenderVhost_SSLDisabled(t *testing.T) { g, tmpDir := setupTestGenerator(t) diff --git a/internal/project/lifecycle.go b/internal/project/lifecycle.go index 33ba775..2c7a336 100644 --- a/internal/project/lifecycle.go +++ b/internal/project/lifecycle.go @@ -167,6 +167,14 @@ func (m *Manager) Start(projectPath string) (*StartResult, error) { result.Errors = append(result.Errors, fmt.Errorf("nginx vhost: %w", err)) } + // The host to store map is shared by every project, so it is rebuilt from + // all of them: nginx accepts only one map per variable. + if err := m.vhostGenerator.EnsureStoreMap(m.collectAllProjectConfigs(cfg)); err != nil { + result.Errors = append(result.Errors, fmt.Errorf("store map: %w", err)) + } + + result.Warnings = append(result.Warnings, cfg.DeprecationWarnings...) + // Reload Nginx to pick up new vhost nginxController := nginx.NewController(m.platform) if err := nginxController.Reload(); err != nil { diff --git a/lib/templates/nginx/vhost.conf.tmpl b/lib/templates/nginx/vhost.conf.tmpl index d4e494a..b83babf 100644 --- a/lib/templates/nginx/vhost.conf.tmpl +++ b/lib/templates/nginx/vhost.conf.tmpl @@ -106,8 +106,6 @@ server { set $MAGE_ROOT {{.DocumentRoot}}; set $MAGE_MODE developer; - set $MAGE_RUN_CODE {{.StoreCode}}; - set $MAGE_RUN_TYPE {{.MageRunType}}; root $MAGE_ROOT; index index.php; @@ -221,8 +219,10 @@ server { fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - fastcgi_param MAGE_RUN_CODE $MAGE_RUN_CODE; - fastcgi_param MAGE_RUN_TYPE $MAGE_RUN_TYPE; +{{- if .HasStoreCodes}} + fastcgi_param MAGE_RUN_CODE $MAGE_RUN_CODE if_not_empty; + fastcgi_param MAGE_RUN_TYPE $MAGE_RUN_TYPE if_not_empty; +{{- end}} include fastcgi_params; } diff --git a/vitepress/guide/faq.md b/vitepress/guide/faq.md index 5abba30..6859ea7 100644 --- a/vitepress/guide/faq.md +++ b/vitepress/guide/faq.md @@ -296,7 +296,7 @@ Magento\Framework\Exception\NoSuchEntityException: The store that was requested Verify the store and try again. ``` -This happens because MageBox sets `MAGE_RUN_CODE` to `default` when no store code is configured for a domain. If your Magento installation's default store code is not `default`, you need to specify the correct store code: +Up to 2.2 MageBox set `MAGE_RUN_CODE` to `default` for every domain, so Magento looked for a store with that exact code. From 2.3.0 it sends nothing unless the domain names a `store_code`, and Magento resolves the store itself. On an older release, or when you do want a specific store per domain, set the code: ```bash magebox domain add mystore.test --store-code=my_store_code diff --git a/vitepress/guide/multi-domain.md b/vitepress/guide/multi-domain.md index 0d69f5f..bdf99cf 100644 --- a/vitepress/guide/multi-domain.md +++ b/vitepress/guide/multi-domain.md @@ -91,9 +91,15 @@ In Magento Admin: ## Store Code Mapping +### How it works + +Hosts that name a `store_code` go into `~/.magebox/nginx/vhosts/000-magebox-store-map.conf`, one file for every project, because nginx accepts only one map per variable. Matching is exact, so `admin.mystore.test` does not inherit the code of `mystore.test`. Domains without a code send nothing and let Magento resolve the store. + +Use `store_type: website` for a code that names a website rather than a store view. Both can appear in one project. + ### Using store_code in Configuration (Recommended) -MageBox supports `store_code` directly in domain configuration. This sets `MAGE_RUN_CODE` in nginx: +MageBox supports `store_code` directly in domain configuration. Every domain that names one is added to a single nginx map shared by all projects, which sets `MAGE_RUN_CODE` and `MAGE_RUN_TYPE` for exactly that host: ```yaml name: mystore diff --git a/vitepress/reference/commands.md b/vitepress/reference/commands.md index ff4efe1..c9716fa 100644 --- a/vitepress/reference/commands.md +++ b/vitepress/reference/commands.md @@ -1410,11 +1410,13 @@ Add a domain to the project. ```bash magebox domain add store.test magebox domain add de.store.test --store-code=german +magebox domain add fr.store.test --store-code=french --store-type=website magebox domain add api.store.test --root=pub --ssl=false ``` **Options:** -- `--store-code` - Magento store code (sets `MAGE_RUN_CODE`) +- `--store-code` - Magento store or website code (sets `MAGE_RUN_CODE` for this host) +- `--store-type` - What the code refers to: `store` (default) or `website` (sets `MAGE_RUN_TYPE`) - `--root` - Document root relative to project (default: `pub`) - `--ssl` - Enable SSL for the domain (default: `true`) diff --git a/vitepress/reference/config-options.md b/vitepress/reference/config-options.md index 5dda6cd..3731af4 100644 --- a/vitepress/reference/config-options.md +++ b/vitepress/reference/config-options.md @@ -57,7 +57,14 @@ domains: | `host` | string | required | Domain name | | `root` | string | `pub` | Document root relative to project | | `ssl` | boolean | `true` | Enable HTTPS | -| `store_code` | string | `default` | Magento store code (sets `MAGE_RUN_CODE`) | +| `store_code` | string | *(none)* | Magento store or website code. Sets `MAGE_RUN_CODE` for this host only when given | +| `store_type` | string | `store` | What the code refers to: `store` or `website`. Sets `MAGE_RUN_TYPE` | + +Leave `store_code` out for a single-store project. MageBox then sends no `MAGE_RUN_CODE` at all and Magento resolves the store itself, which is what a project whose default store code is not `default` needs. + +::: tip Renamed in 2.3.0 +`mage_run_code` and `mage_run_type` are the former names. They still work and print a deprecation warning on `magebox start`; rename them to `store_code` and `store_type`. +::: ---