diff --git a/internal/core/config_flow_test.go b/internal/core/config_flow_test.go index 7b63bb6..b39f188 100644 --- a/internal/core/config_flow_test.go +++ b/internal/core/config_flow_test.go @@ -31,18 +31,27 @@ func TestOrgConfigProfilesFlow(t *testing.T) { t.Fatalf("create org: %v", err) } - form := url.Values{ - "profile_name": {"ESP32", "nRF52"}, - "profile_steps": {"# esp base", "# nrf base"}, - "region_display": {"Mountains"}, - "region_token": {"mtns"}, - "region_layer": {"2"}, - "region_geojson": {`{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`}, + // Two profiles, each created on its own page. + for _, p := range []struct{ name, steps string }{{"ESP32", "# esp base"}, {"nRF52", "# nrf base"}} { + save := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/profiles", + url.Values{"profile_name": {p.name}, "profile_steps": {p.steps}}, sess) + save.Body.Close() + if save.StatusCode != http.StatusSeeOther { + t.Fatalf("create profile %s status = %d, want 303", p.name, save.StatusCode) + } } - save := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/edit", form, sess) - save.Body.Close() - if save.StatusCode != http.StatusSeeOther { - t.Fatalf("save status = %d, want 303", save.StatusCode) + // A geofenced region, saved on the region page. + rsave := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions", url.Values{ + "region_display": {"Mountains"}, + "region_token": {"mtns"}, + "region_layer": {"2"}, + "region_allow_flood": {"1"}, + "root_allow_flood": {"1"}, + "region_geojson": {`{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`}, + }, sess) + rsave.Body.Close() + if rsave.StatusCode != http.StatusSeeOther { + t.Fatalf("save regions status = %d, want 303", rsave.StatusCode) } body := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config", sess)) @@ -63,6 +72,26 @@ func TestOrgConfigProfilesFlow(t *testing.T) { t.Fatalf("?profile=nRF52 should show nRF52's base settings") } + // The admin editor pages render: the hub lists the profiles + region summary, + // the new-profile page shows the base-settings editor, and the region page has + // the map + a region row. + hub := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config/edit", sess)) + for _, want := range []string{"ESP32", "nRF52", "Add profile", "Edit regions", "1 region"} { + if !strings.Contains(hub, want) { + t.Fatalf("config hub missing %q", want) + } + } + newProf := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config/profiles/new", sess)) + if !strings.Contains(newProf, "Base settings") || !strings.Contains(newProf, "Create profile") { + t.Fatalf("new-profile page missing its form") + } + rgn := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions", sess)) + for _, want := range []string{"region-map", "Mountains", "mtns", "Add region", "everywhere", "Allow flood"} { + if !strings.Contains(rgn, want) { + t.Fatalf("regions editor missing %q", want) + } + } + // A non-admin member of a config-less org does NOT see the Configuration tab. plain, _ := st.CreateUser(ctx, "plainmember", "") noCfg, _ := st.CreateOrg(ctx, "NoCfg Org", admin.ID) diff --git a/internal/core/config_profile.go b/internal/core/config_profile.go index 8d2e7c4..1ab949f 100644 --- a/internal/core/config_profile.go +++ b/internal/core/config_profile.go @@ -1,29 +1,22 @@ package core import ( + "errors" "fmt" "net/http" "strconv" "strings" + "github.com/go-chi/chi/v5" + "github.com/jleight/meshtender/internal/geo" "github.com/jleight/meshtender/internal/store" "github.com/jleight/meshtender/internal/web" ) -// Bounds on how many profiles/regions one save may define, so a malformed or -// abusive submission can't insert unbounded rows. -const ( - maxConfigProfiles = 50 - maxConfigRegions = 50 -) - -// configProfileView is a named base-settings profile in the admin editor. -type configProfileView struct { - Name string - StepsText string // editor: one command per line - Steps []store.ConfigStep // read-only: rendered list -} +// Bound on how many regions one save may define, so a malformed or abusive +// submission can't insert unbounded rows. (Profiles are added one at a time.) +const maxConfigRegions = 50 // configRegionView is a region in the admin editor: its display name, MeshCore // token, layer (depth / region def order), and the raw GeoJSON geofence the map @@ -33,6 +26,7 @@ type configRegionView struct { Token string Layer int Primary bool + AllowFlood bool GeofenceJSON string } @@ -77,8 +71,10 @@ func (s *Handlers) pageOrgConfig(w http.ResponseWriter, r *http.Request) { s.Render(w, r, "org_config.html", data) } -// pageOrgConfigEdit is the admin editor for the org's profiles and regions. -func (s *Handlers) pageOrgConfigEdit(w http.ResponseWriter, r *http.Request) { +// pageConfigHub is the admin configuration overview: a list of profiles (each +// edited on its own page) and a summary of the org's regions (edited on the map +// page). It replaces the old single mega-form. +func (s *Handlers) pageConfigHub(w http.ResponseWriter, r *http.Request) { orgID, ok := s.requireOrgAdmin(w, r) if !ok { return @@ -98,20 +94,189 @@ func (s *Handlers) pageOrgConfigEdit(w http.ResponseWriter, r *http.Request) { http.Error(w, "could not load config", http.StatusInternalServerError) return } + var primary string + for _, z := range regions { + if z.Primary { + primary = z.DisplayName + break + } + } s.Render(w, r, "config_edit.html", map[string]any{ - "Org": org, - "Nav": s.OrgNavFor(r.Context(), orgID, org.Slug, "config", true, true), - "EmptyProfile": configProfileView{}, - "EmptyRegion": configRegionView{}, - "Profiles": profileViews(profiles), - "Regions": regionViews(regions), + "Org": org, + "Nav": s.OrgNavFor(r.Context(), orgID, org.Slug, "config", true, true), + "Profiles": profiles, + "RegionCount": len(regions), + "PrimaryRegion": primary, }) } -// handleSaveOrgConfig validates and replaces the org's profiles + regions (admin -// only). Any unknown command line blocks the save and the editor is re-rendered -// with the errors and the entered text preserved. -func (s *Handlers) handleSaveOrgConfig(w http.ResponseWriter, r *http.Request) { +// pageProfileEdit renders the single-profile editor: blank for the /new route, or +// pre-filled when a {pid} is present. 404s if the profile isn't this org's. +func (s *Handlers) pageProfileEdit(w http.ResponseWriter, r *http.Request) { + orgID, ok := s.requireOrgAdmin(w, r) + if !ok { + return + } + org, err := s.Store.GetOrg(r.Context(), orgID) + if err != nil { + http.NotFound(w, r) + return + } + var pid int64 + var name, stepsText string + if raw := chi.URLParam(r, "pid"); raw != "" { + pid, err = strconv.ParseInt(raw, 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + p, err := s.Store.GetProfile(r.Context(), orgID, pid) + if errors.Is(err, store.ErrNotFound) { + http.NotFound(w, r) + return + } + if err != nil { + http.Error(w, "could not load profile", http.StatusInternalServerError) + return + } + name, stepsText = p.Name, stepsToText(p.Steps) + } + s.renderProfileEdit(w, r, org, pid, name, stepsText, nil, nil) +} + +// renderProfileEdit renders the profile editor page (shared by the initial GET +// and the error re-render). pid 0 means a new profile. +func (s *Handlers) renderProfileEdit(w http.ResponseWriter, r *http.Request, org *store.Org, pid int64, name, stepsText string, errs, risky []string) { + s.Render(w, r, "config_profile_edit.html", map[string]any{ + "Org": org, + "Nav": s.OrgNavFor(r.Context(), org.ID, org.Slug, "config", true, true), + "ProfileID": pid, + "Name": name, + "StepsText": stepsText, + "Errors": errs, + "RiskyWarn": risky, + }) +} + +// handleCreateProfile validates and inserts a new profile. +func (s *Handlers) handleCreateProfile(w http.ResponseWriter, r *http.Request) { + orgID, ok := s.requireOrgAdmin(w, r) + if !ok { + return + } + s.saveProfile(w, r, orgID, 0) +} + +// handleUpdateProfile validates and updates an existing profile. +func (s *Handlers) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { + orgID, ok := s.requireOrgAdmin(w, r) + if !ok { + return + } + pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + s.saveProfile(w, r, orgID, pid) +} + +// saveProfile parses the single-profile form and creates (pid 0) or updates it. +// On a validation error it re-renders the editor with the entered text preserved; +// on a duplicate name it reports a friendly message the same way. +func (s *Handlers) saveProfile(w http.ResponseWriter, r *http.Request, orgID, pid int64) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + org, err := s.Store.GetOrg(r.Context(), orgID) + if err != nil { + http.NotFound(w, r) + return + } + catalog, err := s.Store.ListCommands(r.Context()) + if err != nil { + http.Error(w, "could not load commands", http.StatusInternalServerError) + return + } + name := strings.TrimSpace(r.FormValue("profile_name")) + stepsText := r.FormValue("profile_steps") + var errs, risky []string + if name == "" { + errs = append(errs, "Give the profile a name.") + } + _, steps := parseConfigSteps(stepsText, catalog, "profile", &errs, &risky) + + if len(errs) == 0 { + if pid == 0 { + _, err = s.Store.CreateProfile(r.Context(), orgID, name, steps) + } else { + err = s.Store.UpdateProfile(r.Context(), orgID, pid, name, steps) + } + switch { + case errors.Is(err, store.ErrDuplicate): + errs = append(errs, fmt.Sprintf("A profile named %q already exists.", name)) + case errors.Is(err, store.ErrNotFound): + http.NotFound(w, r) + return + case err != nil: + http.Error(w, "could not save profile", http.StatusInternalServerError) + return + default: + http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config/edit", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin + return + } + } + s.renderProfileEdit(w, r, org, pid, name, stepsText, errs, risky) +} + +// handleDeleteProfile removes a profile and returns to the config hub. +func (s *Handlers) handleDeleteProfile(w http.ResponseWriter, r *http.Request) { + orgID, ok := s.requireOrgAdmin(w, r) + if !ok { + return + } + pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + if err := s.Store.DeleteProfile(r.Context(), orgID, pid); err != nil && !errors.Is(err, store.ErrNotFound) { + http.Error(w, "could not delete profile", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config/edit", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin +} + +// pageRegionsEdit renders the region map editor (map + side list). +func (s *Handlers) pageRegionsEdit(w http.ResponseWriter, r *http.Request) { + orgID, ok := s.requireOrgAdmin(w, r) + if !ok { + return + } + org, err := s.Store.GetOrg(r.Context(), orgID) + if err != nil { + http.NotFound(w, r) + return + } + regions, err := s.Store.ListRegions(r.Context(), orgID) + if err != nil { + http.Error(w, "could not load regions", http.StatusInternalServerError) + return + } + s.Render(w, r, "config_regions_edit.html", map[string]any{ + "Org": org, + "Nav": s.OrgNavFor(r.Context(), orgID, org.Slug, "config", true, true), + "EmptyRegion": configRegionView{AllowFlood: true}, + "Regions": regionViews(regions), + "RootAllowFlood": org.RootAllowFlood, + }) +} + +// handleSaveRegions validates and atomically replaces just the org's regions +// (profiles are left untouched). On error the editor is re-rendered with input +// preserved. +func (s *Handlers) handleSaveRegions(w http.ResponseWriter, r *http.Request) { orgID, ok := s.requireOrgAdmin(w, r) if !ok { return @@ -120,79 +285,32 @@ func (s *Handlers) handleSaveOrgConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad form", http.StatusBadRequest) return } - catalog, err := s.Store.ListCommands(r.Context()) - if err != nil { - http.Error(w, "could not load commands", http.StatusInternalServerError) - return - } - - var errs, risky []string - profiles, profileVs := s.parseProfiles(r, catalog, &errs, &risky) + rootAllowFlood := r.FormValue("root_allow_flood") == "1" + var errs []string regions, regionVs := s.parseRegions(r, &errs) - if len(errs) > 0 { org, gerr := s.Store.GetOrg(r.Context(), orgID) if gerr != nil { http.NotFound(w, r) return } - s.Render(w, r, "config_edit.html", map[string]any{ - "Org": org, - "Nav": s.OrgNavFor(r.Context(), orgID, org.Slug, "config", true, true), - "EmptyProfile": configProfileView{}, - "EmptyRegion": configRegionView{}, - "Errors": errs, - "RiskyWarn": risky, - "Profiles": profileVs, - "Regions": regionVs, + s.Render(w, r, "config_regions_edit.html", map[string]any{ + "Org": org, + "Nav": s.OrgNavFor(r.Context(), orgID, org.Slug, "config", true, true), + "EmptyRegion": configRegionView{AllowFlood: true}, + "Errors": errs, + "Regions": regionVs, + "RootAllowFlood": rootAllowFlood, }) return } - - if err := s.Store.ReplaceOrgConfig(r.Context(), orgID, profiles, regions); err != nil { + if err := s.Store.ReplaceRegions(r.Context(), orgID, regions, rootAllowFlood); err != nil { http.Error(w, "could not save", http.StatusInternalServerError) return } http.Redirect(w, r, "/orgs/"+orgParam(r)+"/config", http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } -// parseProfiles reads the repeated profile blocks from the form into store inputs -// and editor views (for re-display on error). Empty blocks are ignored; a block -// with steps but no name, or a duplicate name, is an error. -func (s *Handlers) parseProfiles(r *http.Request, catalog []*store.Command, errs, risky *[]string) ([]store.ProfileInput, []configProfileView) { - names := r.Form["profile_name"] - if len(names) > maxConfigProfiles { - *errs = append(*errs, fmt.Sprintf("Too many profiles (max %d).", maxConfigProfiles)) - } - seen := map[string]bool{} - var ins []store.ProfileInput - var views []configProfileView - for i := range names { - name := strings.TrimSpace(formAt(r, "profile_name", i)) - stepsText := formAt(r, "profile_steps", i) - if name == "" && strings.TrimSpace(stepsText) == "" { - continue // empty block - } - view := configProfileView{Name: name, StepsText: stepsText} - if name == "" { - *errs = append(*errs, "A profile is missing a name.") - views = append(views, view) - continue - } - if seen[strings.ToLower(name)] { - *errs = append(*errs, fmt.Sprintf("Duplicate profile name %q.", name)) - views = append(views, view) - continue - } - seen[strings.ToLower(name)] = true - steps, storeSteps := parseConfigSteps(stepsText, catalog, fmt.Sprintf("profile %q", name), errs, risky) - view.Steps = steps - views = append(views, view) - ins = append(ins, store.ProfileInput{Name: name, Steps: storeSteps}) - } - return ins, views -} - // parseRegions reads the repeated region blocks from the form into store inputs // and editor views. A region needs a token (its MeshCore name); the display name // defaults to the token when left blank. Tokens must be unique and use only @@ -213,10 +331,11 @@ func (s *Handlers) parseRegions(r *http.Request, errs *[]string) ([]store.Region geojson := strings.TrimSpace(formAt(r, "region_geojson", i)) layer, _ := strconv.Atoi(formAt(r, "region_layer", i)) primary := formAt(r, "region_primary", i) == "1" + allowFlood := formAt(r, "region_allow_flood", i) == "1" if token == "" && display == "" && geojson == "" { continue // empty block } - zv := configRegionView{DisplayName: display, Token: token, Layer: layer, Primary: primary, GeofenceJSON: geojson} + zv := configRegionView{DisplayName: display, Token: token, Layer: layer, Primary: primary, AllowFlood: allowFlood, GeofenceJSON: geojson} if token == "" { *errs = append(*errs, "A region is missing its short name.") views = append(views, zv) @@ -237,6 +356,13 @@ func (s *Handlers) parseRegions(r *http.Request, errs *[]string) ([]store.Region display = token zv.DisplayName = display } + // Non-root regions must have a drawn area; the root region (*) is the only + // "applies everywhere" region and lives on the org, not in this list. + if geojson == "" { + *errs = append(*errs, fmt.Sprintf("Region %q needs a drawn area — outline it on the map, or rely on the root region.", token)) + views = append(views, zv) + continue + } geofence, ok := regionGeofence(zv, token, errs) if !ok { views = append(views, zv) @@ -249,7 +375,7 @@ func (s *Handlers) parseRegions(r *http.Request, errs *[]string) ([]store.Region primaryTaken = true } views = append(views, zv) - ins = append(ins, store.RegionInput{Token: token, DisplayName: display, Layer: layer, Primary: primary, GeofenceJSON: geofence}) + ins = append(ins, store.RegionInput{Token: token, DisplayName: display, Layer: layer, Primary: primary, AllowFlood: allowFlood, GeofenceJSON: geofence}) } return ins, views } @@ -348,15 +474,6 @@ func stepsToText(steps []store.ConfigStep) string { return b.String() } -// profileViews converts stored profiles into editor/read views. -func profileViews(profiles []store.Profile) []configProfileView { - out := make([]configProfileView, 0, len(profiles)) - for _, p := range profiles { - out = append(out, configProfileView{Name: p.Name, StepsText: stepsToText(p.Steps), Steps: p.Steps}) - } - return out -} - // regionViews converts stored regions into editor views, carrying each geofence's // raw GeoJSON through verbatim so the map editor round-trips arbitrary polygons. func regionViews(regions []store.Region) []configRegionView { @@ -364,7 +481,7 @@ func regionViews(regions []store.Region) []configRegionView { for _, z := range regions { out = append(out, configRegionView{ DisplayName: z.DisplayName, Token: z.Token, Layer: z.Layer, Primary: z.Primary, - GeofenceJSON: string(z.GeofenceJSON), + AllowFlood: z.AllowFlood, GeofenceJSON: string(z.GeofenceJSON), }) } return out @@ -393,7 +510,11 @@ func (s *Handlers) resolvedProfilesForRepeater(r *http.Request, rep *store.Repea if err != nil { continue } - cmds := store.RegionDefCommands(regions, rep.Latitude, rep.Longitude) + rootAllow, err := s.Store.RootAllowFlood(r.Context(), o.OrgID) + if err != nil { + continue + } + cmds := store.RegionDefCommands(regions, rootAllow, rep.Latitude, rep.Longitude) if len(cmds) == 0 { continue } diff --git a/internal/core/repeater_setup.go b/internal/core/repeater_setup.go index 7e1de54..a2a78c4 100644 --- a/internal/core/repeater_setup.go +++ b/internal/core/repeater_setup.go @@ -97,7 +97,12 @@ func (s *Handlers) handleSetupCommands(w http.ResponseWriter, r *http.Request) { http.Error(w, "could not load regions", http.StatusInternalServerError) return } - cmds = append(cmds, store.RegionDefCommands(regions, req.Lat, req.Lon)...) + rootAllow, err := s.Store.RootAllowFlood(r.Context(), req.OrgID) + if err != nil { + http.Error(w, "could not load regions", http.StatusInternalServerError) + return + } + cmds = append(cmds, store.RegionDefCommands(regions, rootAllow, req.Lat, req.Lon)...) } else { if radio.FreqMHz <= 0 || radio.BwKHz <= 0 { http.Error(w, "radio settings are required", http.StatusBadRequest) diff --git a/internal/core/repeater_setup_test.go b/internal/core/repeater_setup_test.go index a1dd7b5..b446bf1 100644 --- a/internal/core/repeater_setup_test.go +++ b/internal/core/repeater_setup_test.go @@ -53,18 +53,26 @@ func TestSerialSetupFlow(t *testing.T) { // A profile whose steps set the radio, plus a geofenced region (lon 30-40, // lat 10-20). Steps resolve against the seeded command catalog. - form := url.Values{ - "profile_name": {"Std"}, - "profile_steps": {"set radio 910.525,62.5,7,5\nset tx 22"}, - "region_display": {"Zone"}, - "region_token": {"zone"}, - "region_layer": {"1"}, - "region_geojson": {`{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`}, - } - save := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/edit", form, sess) + save := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/profiles", url.Values{ + "profile_name": {"Std"}, + "profile_steps": {"set radio 910.525,62.5,7,5\nset tx 22"}, + }, sess) save.Body.Close() if save.StatusCode != http.StatusSeeOther { - t.Fatalf("config save status = %d, want 303", save.StatusCode) + t.Fatalf("profile save status = %d, want 303", save.StatusCode) + } + // Scoped-flooding pattern: allow flood inside the zone, deny at the root + // (root_allow_flood omitted = unchecked = deny). + rsave := post(t, ts, h.app, "/orgs/"+org.Slug+"/config/regions", url.Values{ + "region_display": {"Zone"}, + "region_token": {"zone"}, + "region_layer": {"1"}, + "region_allow_flood": {"1"}, + "region_geojson": {`{"type":"Polygon","coordinates":[[[30,10],[40,10],[40,20],[30,20],[30,10]]]}`}, + }, sess) + rsave.Body.Close() + if rsave.StatusCode != http.StatusSeeOther { + t.Fatalf("region save status = %d, want 303", rsave.StatusCode) } // Build the command list for a point inside the region. @@ -91,7 +99,8 @@ func TestSerialSetupFlow(t *testing.T) { joined := strings.Join(cmdResp.Commands, "\n") for _, want := range []string{ - "set name Hilltop", "set radio 910.525,62.5,7,5", "region def zone", "region save", + "set name Hilltop", "set radio 910.525,62.5,7,5", "region def zone", + "region denyf *", "region allowf zone", "region save", "set lat 15.000000", "set lon 35.000000", "reboot", } { if !strings.Contains(joined, want) { diff --git a/internal/core/templates/config_edit.html b/internal/core/templates/config_edit.html index ef17193..b95d97b 100644 --- a/internal/core/templates/config_edit.html +++ b/internal/core/templates/config_edit.html @@ -1,9 +1,9 @@ -{{define "title"}}Edit configuration · {{.Org.Name}} · MeshTender{{end}} +{{define "title"}}Configuration · {{.Org.Name}} · MeshTender{{end}} {{define "header"}}
{{.Org.Name}}
-

Edit Configuration

+

Edit configuration

Back to configuration @@ -13,118 +13,52 @@ {{define "content"}} {{template "org-tabs" .Nav}} -{{if .Errors}} -
-
Nothing was saved — fix these first:
-
    {{range .Errors}}
  • {{.}}
  • {{end}}
-
-{{end}} -{{if .RiskyWarn}} -
-
Risky commands in this configuration
-

These can take over or brick a node — included on purpose?

-
    {{range .RiskyWarn}}
  • {{.}}
  • {{end}}
-
-{{end}} - -
-
-

Profiles

-
-

Named sets of base settings — e.g. one per board family. Members pick the profile matching their hardware. One command per line; lines starting with # are notes.

-
- {{range .Profiles}}{{template "profile-block" .}}{{end}} +
+
+
+

Profiles

+
Named sets of base settings — e.g. one per board family. Members pick the profile matching their use case.
+
+ +
+ {{if .Profiles}} +
+ {{range .Profiles}} +
+
+
{{.Name}}
+
{{len .Steps}} step{{if ne (len .Steps) 1}}s{{end}}
- + Edit + + + +
+ {{end}} +
+ {{else}} +

No profiles yet. Add one to give members a base-settings preset for their hardware.

+ {{end}} +
+ +
+
+
+

Regions

+
Named areas drawn on a map. A repeater is auto-assigned to every region whose area contains it, forming its region def.
+
+
- -
-

Regions

-
-

A hierarchy of named areas. Each region has a display name (e.g. Buffalo), a short name used by MeshCore (e.g. buf), and a layer for its depth. Select a region below, then use the map tools to outline where it applies — a region with no shape applies everywhere. A repeater is assigned to every region whose area contains it; sorted by layer, their names form a region def command (e.g. region def us ny buf).

- - -

Drawing area for: —

-
-
- {{range .Regions}}{{template "region-block" .}}{{end}} -
- -
+
+ {{if .RegionCount}} +
{{.RegionCount}} region{{if ne .RegionCount 1}}s{{end}} defined{{if .PrimaryRegion}} · primary: {{.PrimaryRegion}}{{end}}.
+ {{else}} +

No regions yet. Add regions to apply location-based settings automatically.

+ {{end}}
- -
-
-
- - Cancel -
-
-
- - -{{/* Hidden prototypes cloned by addBlock(); kept out of the form via template tags. */}} - - - - - - - -{{end}} - -{{/* profile-block is one editable named profile. */}} -{{define "profile-block"}} -
- - -
- -
- -
-{{end}} - -{{/* region-block is one node of the region hierarchy in the admin form. Its area - is drawn on the shared map above; region_geojson carries the GeoJSON the map - writes. Display name is the human label; Name is the MeshCore token used in - region def; Layer is its depth/order in the chain. */}} -{{define "region-block"}} -
-
-
-
-
-
-
-
-
- - - Applies everywhere - -
-
- -
{{end}} diff --git a/internal/core/templates/config_profile_edit.html b/internal/core/templates/config_profile_edit.html new file mode 100644 index 0000000..98eb41d --- /dev/null +++ b/internal/core/templates/config_profile_edit.html @@ -0,0 +1,60 @@ +{{define "title"}}{{if .ProfileID}}Edit profile{{else}}Add profile{{end}} · {{.Org.Name}} · MeshTender{{end}} +{{define "header"}} +
+
+
{{.Org.Name}} · Configuration
+

{{if .ProfileID}}Edit profile{{else}}Add profile{{end}}

+
+ +
+{{end}} +{{define "content"}} +{{template "org-tabs" .Nav}} + +{{if .Errors}} +
+
Nothing was saved — fix these first:
+
    {{range .Errors}}
  • {{.}}
  • {{end}}
+
+{{end}} +{{if .RiskyWarn}} +
+
Risky commands in this profile
+

These can take over or brick a node — included on purpose?

+
    {{range .RiskyWarn}}
  • {{.}}
  • {{end}}
+
+{{end}} + +
+
+
+
+ + +
+
+ + + One command per line; lines starting with # are notes. +
+
+ + Cancel +
+
+
+
+ +{{if .ProfileID}} +
+
+
+ + Removes this profile and its base settings. +
+
+
+{{end}} +{{end}} diff --git a/internal/core/templates/config_regions_edit.html b/internal/core/templates/config_regions_edit.html new file mode 100644 index 0000000..17bc120 --- /dev/null +++ b/internal/core/templates/config_regions_edit.html @@ -0,0 +1,124 @@ +{{define "title"}}Edit regions · {{.Org.Name}} · MeshTender{{end}} +{{define "header"}} +
+
+
{{.Org.Name}} · Configuration
+

Edit regions

+
+ +
+{{end}} +{{define "content"}} +{{template "org-tabs" .Nav}} + +{{if .Errors}} +
+
Nothing was saved — fix these first:
+
    {{range .Errors}}
  • {{.}}
  • {{end}}
+
+{{end}} + + + + + +

Each region has a display name (e.g. Buffalo), a short name used by MeshCore (e.g. buf), and a level for its depth (lower = broader, e.g. country 1 → state 2 → city 3). Pick a region in the list, then use the map tools to outline where it applies — every region needs an area (the root region at the top of the list is the one that applies everywhere).

+ +
+
+
+
+
+

Draw with the tools at the top-left of the map. New shapes fill the selected region; drawing again adds a new region.

+
+
+
+ {{/* The root region (*) is pinned first in the list. It is not a .region-block, + so the map editor ignores it; its only setting is flood policy. */}} +
+
+
+
Root · * · everywhere
+
Applies to every repeater. Deny flood to scope flooding to your defined regions.
+
+ +
+
+
+ {{range .Regions}}{{template "region-block" .}}{{end}} +
+ +
+ + Cancel +
+
+
+
+ +{{/* Hidden prototype cloned by addBlock(); kept out of the form via the template tag. */}} + + + + + + +{{end}} + +{{/* region-block is one node of the region hierarchy, shown as a card in the side + list. Its area is drawn on the map; region_geojson carries the GeoJSON the map + writes. regionmap.js keys off .region-block / region_geojson / + .region-clear / .region-active. */}} +{{define "region-block"}} +
+
+
+
+
+
+
+
+
+
+
+ + + + + + +
+ +
+
+{{end}} diff --git a/internal/core/web.go b/internal/core/web.go index ecffebf..c616621 100644 --- a/internal/core/web.go +++ b/internal/core/web.go @@ -165,8 +165,14 @@ func (s *Handlers) appRouter() chi.Router { r.Get("/orgs/{id}/my-commands", s.pageOrgCommands) r.Post("/orgs/{id}/my-commands", s.handleSaveOrgCommands) r.Get("/orgs/{id}/config", s.pageOrgConfig) - r.Get("/orgs/{id}/config/edit", s.pageOrgConfigEdit) - r.Post("/orgs/{id}/config/edit", s.handleSaveOrgConfig) + r.Get("/orgs/{id}/config/edit", s.pageConfigHub) + r.Get("/orgs/{id}/config/profiles/new", s.pageProfileEdit) + r.Post("/orgs/{id}/config/profiles", s.handleCreateProfile) + r.Get("/orgs/{id}/config/profiles/{pid}/edit", s.pageProfileEdit) + r.Post("/orgs/{id}/config/profiles/{pid}", s.handleUpdateProfile) + r.Post("/orgs/{id}/config/profiles/{pid}/delete", s.handleDeleteProfile) + r.Get("/orgs/{id}/config/regions", s.pageRegionsEdit) + r.Post("/orgs/{id}/config/regions", s.handleSaveRegions) // Custom org domains are hidden for now — the hosting/TLS infrastructure // isn't in place yet. Leave the handlers in tree but don't expose them. // r.Post("/orgs/{id}/domains", s.handleAddOrgDomain) diff --git a/internal/store/config_profiles.go b/internal/store/config_profiles.go index 376f50f..459d6d6 100644 --- a/internal/store/config_profiles.go +++ b/internal/store/config_profiles.go @@ -52,6 +52,7 @@ type Region struct { DisplayName string Layer int Primary bool // the org's primary region (frames the config preview map) + AllowFlood bool // whether flooding is allowed in this region (region allowf/denyf) Geofence *geo.Shape GeofenceJSON []byte } @@ -67,6 +68,7 @@ type RegionInput struct { DisplayName string Layer int Primary bool + AllowFlood bool GeofenceJSON []byte } @@ -128,14 +130,14 @@ func (s *Store) ListProfiles(ctx context.Context, orgID int64) ([]Profile, error // the order their tokens appear in a `region def` chain. func (s *Store) ListRegions(ctx context.Context, orgID int64) ([]Region, error) { rrows, err := s.pool.Query(ctx, - `SELECT id, token, display_name, layer, is_primary, geofence FROM config_regions WHERE org_id = $1 ORDER BY layer, token`, orgID) + `SELECT id, token, display_name, layer, is_primary, allow_flood, geofence FROM config_regions WHERE org_id = $1 ORDER BY layer, token`, orgID) if err != nil { return nil, fmt.Errorf("list regions: %w", err) } regions, err := collectRows(rrows, func(r pgx.Row) (Region, error) { var z Region var raw []byte - if err := r.Scan(&z.ID, &z.Token, &z.DisplayName, &z.Layer, &z.Primary, &raw); err != nil { + if err := r.Scan(&z.ID, &z.Token, &z.DisplayName, &z.Layer, &z.Primary, &z.AllowFlood, &raw); err != nil { return Region{}, err } if z.Geofence, err = geo.Parse(raw); err != nil { @@ -155,39 +157,159 @@ func (s *Store) ListRegions(ctx context.Context, orgID int64) ([]Region, error) // version history — so the editor sends the full desired state each save. func (s *Store) ReplaceOrgConfig(ctx context.Context, orgID int64, profiles []ProfileInput, regions []RegionInput) error { return s.inTx(ctx, func(tx pgx.Tx) error { - // Steps cascade from their profile/region, so deleting the parents is enough. if _, err := tx.Exec(ctx, `DELETE FROM config_profiles WHERE org_id = $1`, orgID); err != nil { return fmt.Errorf("clear profiles: %w", err) } - if _, err := tx.Exec(ctx, `DELETE FROM config_regions WHERE org_id = $1`, orgID); err != nil { - return fmt.Errorf("clear regions: %w", err) - } for pos, p := range profiles { - var pid int64 - if err := tx.QueryRow(ctx, - `INSERT INTO config_profiles (org_id, name, position) VALUES ($1, $2, $3) RETURNING id`, - orgID, p.Name, pos).Scan(&pid); err != nil { - return fmt.Errorf("insert profile %q: %w", p.Name, err) - } - if err := insertConfigSteps(ctx, tx, "config_profile_steps", "profile_id", pid, p.Steps); err != nil { - return fmt.Errorf("insert profile %q steps: %w", p.Name, err) + if _, err := insertProfile(ctx, tx, orgID, p.Name, pos, p.Steps); err != nil { + return err } } - for _, z := range regions { - var geofence []byte // nil → SQL NULL → everywhere - if len(z.GeofenceJSON) > 0 { - geofence = z.GeofenceJSON - } - if _, err := tx.Exec(ctx, - `INSERT INTO config_regions (org_id, token, display_name, layer, is_primary, geofence) VALUES ($1, $2, $3, $4, $5, $6)`, - orgID, z.Token, z.DisplayName, z.Layer, z.Primary, geofence); err != nil { - return fmt.Errorf("insert region %q: %w", z.Token, err) - } - } - return nil + return replaceRegionsTx(ctx, tx, orgID, regions) }) } +// ReplaceRegions replaces just an org's regions (profiles untouched) and sets the +// org's root (*) flood policy, in one transaction. Regions are spatially +// interdependent — parenting is derived from overlap — so the region editor sends +// the full desired set each save. +func (s *Store) ReplaceRegions(ctx context.Context, orgID int64, regions []RegionInput, rootAllowFlood bool) error { + return s.inTx(ctx, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, + `UPDATE organizations SET root_allow_flood = $2 WHERE id = $1`, orgID, rootAllowFlood); err != nil { + return fmt.Errorf("set root flood: %w", err) + } + return replaceRegionsTx(ctx, tx, orgID, regions) + }) +} + +// RootAllowFlood reports whether flooding is allowed at the org's root region (*). +func (s *Store) RootAllowFlood(ctx context.Context, orgID int64) (bool, error) { + var allow bool + if err := s.pool.QueryRow(ctx, + `SELECT root_allow_flood FROM organizations WHERE id = $1`, orgID).Scan(&allow); err != nil { + return false, fmt.Errorf("root allow flood: %w", err) + } + return allow, nil +} + +// CreateProfile inserts a new named profile for an org, appended after existing +// ones. Returns ErrDuplicate if the org already has a profile with that name. +func (s *Store) CreateProfile(ctx context.Context, orgID int64, name string, steps []ConfigStep) (int64, error) { + var id int64 + err := s.inTx(ctx, func(tx pgx.Tx) error { + var pos int + if err := tx.QueryRow(ctx, + `SELECT count(*) FROM config_profiles WHERE org_id = $1`, orgID).Scan(&pos); err != nil { + return fmt.Errorf("count profiles: %w", err) + } + var err error + id, err = insertProfile(ctx, tx, orgID, name, pos, steps) + return err + }) + if isUniqueViolation(err) { + return 0, ErrDuplicate + } + return id, err +} + +// GetProfile returns a single profile (with steps) scoped to its org, or +// ErrNotFound if no such profile belongs to the org. +func (s *Store) GetProfile(ctx context.Context, orgID, profileID int64) (*Profile, error) { + var p Profile + err := s.pool.QueryRow(ctx, + `SELECT id, name, position FROM config_profiles WHERE id = $1 AND org_id = $2`, profileID, orgID). + Scan(&p.ID, &p.Name, &p.Position) + if err != nil { + return nil, notFoundOr(err, "get profile") + } + rows, err := s.pool.Query(ctx, + `SELECT position, command_line, command_id, comment FROM config_profile_steps + WHERE profile_id = $1 ORDER BY position, id`, profileID) + if err != nil { + return nil, fmt.Errorf("get profile steps: %w", err) + } + p.Steps, err = collectRows(rows, func(r pgx.Row) (ConfigStep, error) { + var st ConfigStep + return st, r.Scan(&st.Position, &st.CommandLine, &st.CommandID, &st.Comment) + }) + if err != nil { + return nil, err + } + return &p, nil +} + +// UpdateProfile renames a profile and replaces its steps, scoped to its org. Its +// position is preserved. Returns ErrNotFound if the profile isn't the org's, or +// ErrDuplicate if the new name collides with another profile. +func (s *Store) UpdateProfile(ctx context.Context, orgID, profileID int64, name string, steps []ConfigStep) error { + err := s.inTx(ctx, func(tx pgx.Tx) error { + tag, err := tx.Exec(ctx, + `UPDATE config_profiles SET name = $3 WHERE id = $1 AND org_id = $2`, profileID, orgID, name) + if err != nil { + return fmt.Errorf("rename profile: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + if _, err := tx.Exec(ctx, `DELETE FROM config_profile_steps WHERE profile_id = $1`, profileID); err != nil { + return fmt.Errorf("clear profile steps: %w", err) + } + return insertConfigSteps(ctx, tx, "config_profile_steps", "profile_id", profileID, steps) + }) + if isUniqueViolation(err) { + return ErrDuplicate + } + return err +} + +// DeleteProfile removes a profile (its steps cascade), scoped to its org. +// Returns ErrNotFound if no such profile belongs to the org. +func (s *Store) DeleteProfile(ctx context.Context, orgID, profileID int64) error { + tag, err := s.pool.Exec(ctx, + `DELETE FROM config_profiles WHERE id = $1 AND org_id = $2`, profileID, orgID) + if err != nil { + return fmt.Errorf("delete profile: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// insertProfile inserts one profile row plus its steps, returning the new id. +func insertProfile(ctx context.Context, tx pgx.Tx, orgID int64, name string, pos int, steps []ConfigStep) (int64, error) { + var pid int64 + if err := tx.QueryRow(ctx, + `INSERT INTO config_profiles (org_id, name, position) VALUES ($1, $2, $3) RETURNING id`, + orgID, name, pos).Scan(&pid); err != nil { + return 0, fmt.Errorf("insert profile %q: %w", name, err) + } + if err := insertConfigSteps(ctx, tx, "config_profile_steps", "profile_id", pid, steps); err != nil { + return 0, fmt.Errorf("insert profile %q steps: %w", name, err) + } + return pid, nil +} + +// replaceRegionsTx clears an org's regions and inserts the given set within tx. +func replaceRegionsTx(ctx context.Context, tx pgx.Tx, orgID int64, regions []RegionInput) error { + if _, err := tx.Exec(ctx, `DELETE FROM config_regions WHERE org_id = $1`, orgID); err != nil { + return fmt.Errorf("clear regions: %w", err) + } + for _, z := range regions { + var geofence []byte // nil → SQL NULL → everywhere + if len(z.GeofenceJSON) > 0 { + geofence = z.GeofenceJSON + } + if _, err := tx.Exec(ctx, + `INSERT INTO config_regions (org_id, token, display_name, layer, is_primary, allow_flood, geofence) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + orgID, z.Token, z.DisplayName, z.Layer, z.Primary, z.AllowFlood, geofence); err != nil { + return fmt.Errorf("insert region %q: %w", z.Token, err) + } + } + return nil +} + // insertConfigSteps writes an ordered run of steps into a step table keyed by a // parent id column. table/parentCol are fixed internal constants — never user // input — so the interpolation is safe. @@ -240,12 +362,18 @@ func betterParent(a, b Region) bool { // RegionDefCommands renders the regions that apply at (lat, lon) into the MeshCore // commands to run on a repeater: a single `region def …` line describing the -// region tree for the location, followed by `region save`. The tree is the subset -// of regions whose geofence contains the point (match-all regions always apply), -// re-parented onto their nearest matching ancestor and serialized depth-first with -// the `child|ancestor` pop-back syntax so sibling branches (overlapping same-layer -// regions) come out correctly. Returns nil when no region covers the location. -func RegionDefCommands(regions []Region, lat, lon *float64) []string { +// region tree for the location, then explicit flood policy for the root (*) and +// each applied region, then `region save`. The tree is the subset of regions whose +// geofence contains the point (match-all regions always apply), re-parented onto +// their nearest matching ancestor and serialized depth-first with the +// `child|ancestor` pop-back syntax so sibling branches (overlapping same-layer +// regions) come out correctly. Flood lines are always explicit (allowf/denyf) so +// applying the config normalizes the node over any prior manual settings. +// +// Returns nil when no region covers the location. This is the safety guard: a lone +// `region denyf *` with nothing allowed would kill all flooding, so the root deny +// only ever ships alongside a real def + at least one applied region. +func RegionDefCommands(regions []Region, rootAllowFlood bool, lat, lon *float64) []string { parents := regionParents(regions) matched := make([]bool, len(regions)) any := false @@ -322,7 +450,21 @@ func RegionDefCommands(regions []Region, lat, lon *float64) []string { } tokens = append(tokens, regions[node].Token) } - return []string{"region def " + strings.Join(tokens, " "), "region save"} + // Def line, then explicit flood policy (root first, then each applied region in + // def order), then persist. + out := []string{"region def " + strings.Join(tokens, " "), floodCommand("*", rootAllowFlood)} + for _, node := range seq { + out = append(out, floodCommand(regions[node].Token, regions[node].AllowFlood)) + } + return append(out, "region save") +} + +// floodCommand returns the MeshCore command setting a region's flood policy. +func floodCommand(name string, allow bool) string { + if allow { + return "region allowf " + name + } + return "region denyf " + name } // RegionMatches reports whether a region applies at (lat, lon): a match-all region diff --git a/internal/store/config_profiles_test.go b/internal/store/config_profiles_test.go index f545d5f..739731a 100644 --- a/internal/store/config_profiles_test.go +++ b/internal/store/config_profiles_test.go @@ -78,6 +78,129 @@ func TestOrgConfigReplaceAndRead(t *testing.T) { } } +func TestProfileCRUD(t *testing.T) { + t.Parallel() + st, ctx := orgTestStore(t) + owner, err := st.CreateUser(ctx, "profowner", "") + if err != nil { + t.Fatal(err) + } + org, err := st.CreateOrg(ctx, "ProfOrg", owner.ID) + if err != nil { + t.Fatal(err) + } + + id, err := st.CreateProfile(ctx, org.ID, "ESP32", []ConfigStep{{CommandLine: "set tx 22"}, {Comment: "note"}}) + if err != nil { + t.Fatalf("create: %v", err) + } + // Duplicate name (case-insensitive per the unique constraint is not guaranteed; + // exact-name collide is). + if _, err := st.CreateProfile(ctx, org.ID, "ESP32", nil); err != ErrDuplicate { + t.Fatalf("duplicate create err = %v, want ErrDuplicate", err) + } + + got, err := st.GetProfile(ctx, org.ID, id) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Name != "ESP32" || len(got.Steps) != 2 || got.Steps[0].CommandLine != "set tx 22" { + t.Fatalf("get profile = %+v", got) + } + + // Wrong org can't see it. + other, err := st.CreateOrg(ctx, "OtherOrg", owner.ID) + if err != nil { + t.Fatal(err) + } + if _, err := st.GetProfile(ctx, other.ID, id); err != ErrNotFound { + t.Fatalf("cross-org get err = %v, want ErrNotFound", err) + } + + // Update: rename + replace steps, position preserved. + if err := st.UpdateProfile(ctx, org.ID, id, "Heltec", []ConfigStep{{CommandLine: "set tx 20"}}); err != nil { + t.Fatalf("update: %v", err) + } + got, _ = st.GetProfile(ctx, org.ID, id) + if got.Name != "Heltec" || len(got.Steps) != 1 || got.Steps[0].CommandLine != "set tx 20" { + t.Fatalf("after update = %+v", got) + } + // Update non-owned → ErrNotFound. + if err := st.UpdateProfile(ctx, other.ID, id, "X", nil); err != ErrNotFound { + t.Fatalf("cross-org update err = %v, want ErrNotFound", err) + } + + // A second profile, then a name clash on update → ErrDuplicate. + id2, err := st.CreateProfile(ctx, org.ID, "Second", nil) + if err != nil { + t.Fatal(err) + } + if err := st.UpdateProfile(ctx, org.ID, id2, "Heltec", nil); err != ErrDuplicate { + t.Fatalf("update to dup name err = %v, want ErrDuplicate", err) + } + + // Delete: gone, and idempotency guard returns ErrNotFound on re-delete. + if err := st.DeleteProfile(ctx, org.ID, id); err != nil { + t.Fatalf("delete: %v", err) + } + if err := st.DeleteProfile(ctx, org.ID, id); err != ErrNotFound { + t.Fatalf("re-delete err = %v, want ErrNotFound", err) + } + remaining, _ := st.ListProfiles(ctx, org.ID) + if len(remaining) != 1 || remaining[0].Name != "Second" { + t.Fatalf("after delete, profiles = %+v", remaining) + } +} + +func TestReplaceRegionsKeepsProfiles(t *testing.T) { + t.Parallel() + st, ctx := orgTestStore(t) + owner, err := st.CreateUser(ctx, "rgnowner", "") + if err != nil { + t.Fatal(err) + } + org, err := st.CreateOrg(ctx, "RgnOrg", owner.ID) + if err != nil { + t.Fatal(err) + } + if _, err := st.CreateProfile(ctx, org.ID, "Keep", nil); err != nil { + t.Fatal(err) + } + + if err := st.ReplaceRegions(ctx, org.ID, []RegionInput{ + {Token: "country", DisplayName: "Country", Layer: 1, AllowFlood: true}, + {Token: "metro", DisplayName: "Metro", Layer: 2, Primary: true, AllowFlood: true, GeofenceJSON: geo.Rectangle(10, 30, 20, 40)}, + }, false); err != nil { + t.Fatalf("replace regions: %v", err) + } + // The root (*) flood policy round-trips (we passed deny above). + if allow, err := st.RootAllowFlood(ctx, org.ID); err != nil || allow { + t.Fatalf("root allow flood = %v (err %v), want false", allow, err) + } + // Regions saved and profiles untouched. + gotR, _ := st.ListRegions(ctx, org.ID) + if len(gotR) != 2 { + t.Fatalf("regions = %d, want 2", len(gotR)) + } + gotP, _ := st.ListProfiles(ctx, org.ID) + if len(gotP) != 1 || gotP[0].Name != "Keep" { + t.Fatalf("profiles after region replace = %+v, want [Keep]", gotP) + } + + // Replacing with an empty set clears regions but still keeps profiles. + if err := st.ReplaceRegions(ctx, org.ID, nil, true); err != nil { + t.Fatalf("clear regions: %v", err) + } + gotR, _ = st.ListRegions(ctx, org.ID) + if len(gotR) != 0 { + t.Fatalf("regions after clear = %d, want 0", len(gotR)) + } + gotP, _ = st.ListProfiles(ctx, org.ID) + if len(gotP) != 1 { + t.Fatalf("profiles after clear = %d, want 1", len(gotP)) + } +} + func TestRegionDefCommands(t *testing.T) { t.Parallel() @@ -86,7 +209,7 @@ func TestRegionDefCommands(t *testing.T) { if err != nil { t.Fatalf("parse %s: %v", token, err) } - return Region{Token: token, Layer: layer, Geofence: shape} + return Region{Token: token, Layer: layer, AllowFlood: true, Geofence: shape} } // us (L1) covers everything; ny and pa (L2) sit inside us and overlap each @@ -100,9 +223,10 @@ func TestRegionDefCommands(t *testing.T) { def := func(label string, lat, lon float64, want string) { t.Helper() - got := RegionDefCommands(regions, ptr(lat), ptr(lon)) - if len(got) != 2 || got[0] != want || got[1] != "region save" { - t.Fatalf("%s = %v, want [%q, region save]", label, got, want) + // The def line comes first; flood lines follow; "region save" is always last. + got := RegionDefCommands(regions, true, ptr(lat), ptr(lon)) + if len(got) < 2 || got[0] != want || got[len(got)-1] != "region save" { + t.Fatalf("%s = %v, want [%q, …, region save]", label, got, want) } } @@ -113,11 +237,43 @@ func TestRegionDefCommands(t *testing.T) { // In pa only (lon 40) → linear us → pa. def("pa only", 15, 40, "region def us pa") - // Unknown / outside location → no commands. - if got := RegionDefCommands(regions, nil, nil); got != nil { + // Unknown / outside location → no commands (the safety guard: never a lone denyf *). + if got := RegionDefCommands(regions, true, nil, nil); got != nil { t.Fatalf("no location: got %v, want nil", got) } - if got := RegionDefCommands(regions, ptr(-5.0), ptr(-5.0)); got != nil { + if got := RegionDefCommands(regions, false, ptr(-5.0), ptr(-5.0)); got != nil { t.Fatalf("outside: got %v, want nil", got) } } + +func TestRegionDefFloodCommands(t *testing.T) { + t.Parallel() + mk := func(token string, layer int, allow bool, box []byte) Region { + shape, err := geo.Parse(box) + if err != nil { + t.Fatalf("parse %s: %v", token, err) + } + return Region{Token: token, Layer: layer, AllowFlood: allow, Geofence: shape} + } + // us allows flood, ny denies it; root denies flood (scoped-flooding pattern). + regions := []Region{ + mk("us", 1, true, geo.Rectangle(0, 0, 100, 100)), + mk("ny", 2, false, geo.Rectangle(10, 10, 30, 30)), + } + got := RegionDefCommands(regions, false, ptr(15.0), ptr(15.0)) + want := []string{ + "region def us ny", + "region denyf *", + "region allowf us", + "region denyf ny", + "region save", + } + if len(got) != len(want) { + t.Fatalf("commands = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("commands[%d] = %q, want %q (full %v)", i, got[i], want[i], got) + } + } +} diff --git a/internal/store/migrations/0032_region_flood.sql b/internal/store/migrations/0032_region_flood.sql new file mode 100644 index 0000000..af446ed --- /dev/null +++ b/internal/store/migrations/0032_region_flood.sql @@ -0,0 +1,12 @@ +-- +goose Up +-- Per-region flood policy and the org's root (*) flood policy. MeshCore controls +-- flooding per region via `region allowf ` / `region denyf ` (name may +-- be the wildcard root *). Defaults are TRUE so existing configs keep flooding +-- allowed everywhere until an admin opts into scoped flooding (deny at root, allow +-- in defined regions). +ALTER TABLE config_regions ADD COLUMN allow_flood BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE organizations ADD COLUMN root_allow_flood BOOLEAN NOT NULL DEFAULT TRUE; + +-- +goose Down +ALTER TABLE organizations DROP COLUMN root_allow_flood; +ALTER TABLE config_regions DROP COLUMN allow_flood; diff --git a/internal/store/orgs.go b/internal/store/orgs.go index 1fec4bb..3a4014b 100644 --- a/internal/store/orgs.go +++ b/internal/store/orgs.go @@ -19,8 +19,11 @@ type Org struct { Name string Description string Region string - CreatedBy *int64 - CreatedAt time.Time + // RootAllowFlood is the flood policy for the org's root region (*): whether + // flooding is allowed everywhere by default. See RegionDefCommands. + RootAllowFlood bool + CreatedBy *int64 + CreatedAt time.Time } // OrgSummary is a public directory entry for an organization. @@ -115,8 +118,8 @@ func (s *Store) CreateOrg(ctx context.Context, name string, creatorID int64) (*O } if err := tx.QueryRow(ctx, `INSERT INTO organizations (slug, name, created_by) VALUES ($1, $2, $3) - RETURNING id, slug, name, description, region, created_by, created_at`, - slug, name, creatorID).Scan(&o.ID, &o.Slug, &o.Name, &o.Description, &o.Region, &o.CreatedBy, &o.CreatedAt); err != nil { + RETURNING id, slug, name, description, region, root_allow_flood, created_by, created_at`, + slug, name, creatorID).Scan(&o.ID, &o.Slug, &o.Name, &o.Description, &o.Region, &o.RootAllowFlood, &o.CreatedBy, &o.CreatedAt); err != nil { return fmt.Errorf("insert org: %w", err) } if _, err := tx.Exec(ctx, @@ -138,8 +141,8 @@ func (s *Store) CreateOrg(ctx context.Context, name string, creatorID int64) (*O func (s *Store) GetOrg(ctx context.Context, id int64) (*Org, error) { var o Org err := s.pool.QueryRow(ctx, - `SELECT id, slug, name, description, region, created_by, created_at FROM organizations WHERE id = $1`, id). - Scan(&o.ID, &o.Slug, &o.Name, &o.Description, &o.Region, &o.CreatedBy, &o.CreatedAt) + `SELECT id, slug, name, description, region, root_allow_flood, created_by, created_at FROM organizations WHERE id = $1`, id). + Scan(&o.ID, &o.Slug, &o.Name, &o.Description, &o.Region, &o.RootAllowFlood, &o.CreatedBy, &o.CreatedAt) if err != nil { return nil, notFoundOr(err, "get org") } diff --git a/internal/web/orgview.go b/internal/web/orgview.go index bf6ffa5..08e2209 100644 --- a/internal/web/orgview.go +++ b/internal/web/orgview.go @@ -114,7 +114,11 @@ func BuildConfigView(ctx context.Context, st *store.Store, orgID int64, selected cv.MapBounds = []float64{union.minLat, union.minLon, union.maxLat, union.maxLon} } if cv.PreviewActive { - cv.RegionDef = store.RegionDefCommands(regions, lat, lon) + rootAllow, err := st.RootAllowFlood(ctx, orgID) + if err != nil { + return ConfigView{}, err + } + cv.RegionDef = store.RegionDefCommands(regions, rootAllow, lat, lon) } return cv, nil } diff --git a/internal/web/static/regionmap.js b/internal/web/static/regionmap.js index 346f216..749257f 100644 --- a/internal/web/static/regionmap.js +++ b/internal/web/static/regionmap.js @@ -98,7 +98,7 @@ map.on("pm:drawend", function () { drawing = false; }); function setStatus(b) { - if (b.status) b.status.textContent = b.layer ? "Custom area" : "Applies everywhere"; + if (b.status) b.status.textContent = b.layer ? "Custom area" : "No area yet"; } function serialize(b) { b.hidden.value = b.layer ? geomString(b.layer) : ""; @@ -111,7 +111,16 @@ } function setActive(b) { active = b; - blocks.forEach(function (x) { x.el.classList.toggle("region-active", x === b); }); + blocks.forEach(function (x) { + var on = x === b; + x.el.classList.toggle("region-active", on); + // The Edit button is the visible selection affordance: it lights up and + // reads "Editing" for the active region, "Edit" otherwise. + if (x.edit) { + x.edit.classList.toggle("btn-primary", on); + x.edit.textContent = on ? "Editing" : "Edit"; + } + }); if (banner) banner.textContent = b ? regionName(b) : "—"; restyle(); } @@ -163,12 +172,15 @@ el: el, hidden: el.querySelector('input[name="region_geojson"]'), status: el.querySelector(".region-shape-status"), + edit: el.querySelector(".region-edit-btn"), layer: null, }; var layer = layerFromGeoJSON(b.hidden && b.hidden.value); if (layer) bindLayer(b, layer); setStatus(b); - el.addEventListener("click", function () { if (!drawing) setActive(b); }); + // Selection is driven by the explicit Edit button (and clicking the shape on + // the map) — the card itself isn't clickable, which read as unintuitive. + if (b.edit) b.edit.addEventListener("click", function () { if (!drawing) setActive(b); }); var clearBtn = el.querySelector(".region-clear"); if (clearBtn) clearBtn.addEventListener("click", function (e) { e.stopPropagation();