package web import ( "fmt" "net/http" "strings" "github.com/owen/vibedns/internal/app" "github.com/owen/vibedns/internal/models" ) // --- Client networks ---------------------------------------------------- func (s *Server) handleNetworks(w http.ResponseWriter, r *http.Request) error { search := formString(r, "q") networks, err := s.app.Networks(r.Context(), search) if err != nil { return err } policies, err := s.app.Policies(r.Context()) if err != nil { return err } data := s.base(r, "Client Networks", "networks") data.Data = map[string]any{ "Networks": networks, "Policies": policies, "Search": search, } s.render(w, r, "networks", data) return nil } func (s *Server) handleNetworkNew(w http.ResponseWriter, r *http.Request) error { policies, err := s.app.Policies(r.Context()) if err != nil { return err } data := s.base(r, "New Client Network", "networks") data.Data = map[string]any{ "Network": models.Network{Enabled: true}, "Policies": policies, "Selected": map[int64]bool{}, "IsNew": true, "FormAction": "/policies/networks/new", } s.render(w, r, "network_form", data) return nil } func networkInputFromForm(r *http.Request) app.NetworkInput { enabled := formBool(r, "enabled") return app.NetworkInput{ Name: formString(r, "name"), CIDR: formString(r, "cidr"), Description: formString(r, "description"), Enabled: &enabled, PolicyIDs: formInt64s(r, "policy_id"), } } func (s *Server) handleNetworkCreate(w http.ResponseWriter, r *http.Request) error { if err := parseForm(r); err != nil { return err } n, err := s.app.CreateNetwork(r.Context(), s.actor(r), networkInputFromForm(r)) if err != nil { return err } setFlash(w, r, "success", fmt.Sprintf("Network %s created.", n.Name)) return s.redirect(w, r, "/policies/networks") } func (s *Server) handleNetworkEdit(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } network, err := s.app.Network(r.Context(), id) if err != nil { return err } policies, err := s.app.Policies(r.Context()) if err != nil { return err } selected := map[int64]bool{} for _, p := range network.Policies { selected[p.ID] = true } data := s.base(r, network.Name, "networks") data.Data = map[string]any{ "Network": network, "Policies": policies, "Selected": selected, "IsNew": false, "FormAction": fmt.Sprintf("/policies/networks/%d", id), } s.render(w, r, "network_form", data) return nil } func (s *Server) handleNetworkUpdate(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := parseForm(r); err != nil { return err } n, err := s.app.UpdateNetwork(r.Context(), s.actor(r), id, networkInputFromForm(r)) if err != nil { return err } setFlash(w, r, "success", fmt.Sprintf("Network %s saved.", n.Name)) return s.redirect(w, r, "/policies/networks") } func (s *Server) handleNetworkToggle(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := parseForm(r); err != nil { return err } enabled := formBool(r, "enabled") if err := s.app.SetNetworkEnabled(r.Context(), s.actor(r), id, enabled); err != nil { return err } setFlash(w, r, "success", "Network "+enabledWord(enabled)+".") s.redirectBack(w, r) return nil } func (s *Server) handleNetworkDelete(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := parseForm(r); err != nil { return err } if err := s.app.DeleteNetwork(r.Context(), s.actor(r), id); err != nil { return err } setFlash(w, r, "success", "Network deleted.") return s.redirect(w, r, "/policies/networks") } func enabledWord(enabled bool) string { if enabled { return "enabled" } return "disabled" } // --- Policies ----------------------------------------------------------- func (s *Server) handlePolicies(w http.ResponseWriter, r *http.Request) error { policies, err := s.app.Policies(r.Context()) if err != nil { return err } data := s.base(r, "Policy Rules", "policies") data.Data = map[string]any{"Policies": policies} s.render(w, r, "policies", data) return nil } func (s *Server) handlePolicyNew(w http.ResponseWriter, r *http.Request) error { lists, err := s.app.DomainLists(r.Context(), "", "") if err != nil { return err } data := s.base(r, "New Policy", "policies") data.Data = map[string]any{ "Policy": models.Policy{ Enabled: true, BlockAction: models.BlockNXDOMAIN, BlockTTL: 60, SinkholeIPv4: "0.0.0.0", SinkholeIPv6: "::", }, "Blacklists": filterLists(lists, models.KindBlacklist), "Allowlists": filterLists(lists, models.KindAllowlist), "Selected": map[int64]bool{}, "IsNew": true, "FormAction": "/policies/rules/new", } s.render(w, r, "policy_form", data) return nil } func filterLists(lists []models.DomainList, kind string) []models.DomainList { var out []models.DomainList for _, l := range lists { if l.Kind == kind { out = append(out, l) } } return out } func policyInputFromForm(r *http.Request) app.PolicyInput { enabled := formBool(r, "enabled") return app.PolicyInput{ Name: formString(r, "name"), Description: formString(r, "description"), Enabled: &enabled, BlockAction: formString(r, "block_action"), SinkholeIPv4: formString(r, "sinkhole_ipv4"), SinkholeIPv6: formString(r, "sinkhole_ipv6"), BlockTTL: formUint32(r, "block_ttl", 0), ListIDs: formInt64s(r, "list_id"), } } func (s *Server) handlePolicyCreate(w http.ResponseWriter, r *http.Request) error { if err := parseForm(r); err != nil { return err } p, err := s.app.CreatePolicy(r.Context(), s.actor(r), policyInputFromForm(r)) if err != nil { return err } setFlash(w, r, "success", fmt.Sprintf("Policy %s created.", p.Name)) return s.redirect(w, r, "/policies") } func (s *Server) handlePolicyEdit(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } policy, err := s.app.Policy(r.Context(), id) if err != nil { return err } lists, err := s.app.DomainLists(r.Context(), "", "") if err != nil { return err } selected := map[int64]bool{} for _, lid := range policy.BlacklistIDs { selected[lid] = true } for _, lid := range policy.AllowlistIDs { selected[lid] = true } data := s.base(r, policy.Name, "policies") data.Data = map[string]any{ "Policy": policy, "Blacklists": filterLists(lists, models.KindBlacklist), "Allowlists": filterLists(lists, models.KindAllowlist), "Selected": selected, "IsNew": false, "FormAction": fmt.Sprintf("/policies/rules/%d", id), } s.render(w, r, "policy_form", data) return nil } func (s *Server) handlePolicyUpdate(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := parseForm(r); err != nil { return err } p, err := s.app.UpdatePolicy(r.Context(), s.actor(r), id, policyInputFromForm(r)) if err != nil { return err } setFlash(w, r, "success", fmt.Sprintf("Policy %s saved.", p.Name)) return s.redirect(w, r, "/policies") } func (s *Server) handlePolicyToggle(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := parseForm(r); err != nil { return err } enabled := formBool(r, "enabled") if err := s.app.SetPolicyEnabled(r.Context(), s.actor(r), id, enabled); err != nil { return err } setFlash(w, r, "success", "Policy "+enabledWord(enabled)+".") s.redirectBack(w, r) return nil } func (s *Server) handlePolicyDelete(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := parseForm(r); err != nil { return err } if err := s.app.DeletePolicy(r.Context(), s.actor(r), id); err != nil { return err } setFlash(w, r, "success", "Policy deleted.") return s.redirect(w, r, "/policies") } // --- Domain lists ------------------------------------------------------- func (s *Server) handleBlacklists(w http.ResponseWriter, r *http.Request) error { return s.renderLists(w, r, models.KindBlacklist, "Blacklists", "blacklists") } func (s *Server) handleAllowlists(w http.ResponseWriter, r *http.Request) error { return s.renderLists(w, r, models.KindAllowlist, "Allowlists", "allowlists") } func (s *Server) renderLists(w http.ResponseWriter, r *http.Request, kind, title, nav string) error { search := formString(r, "q") lists, err := s.app.DomainLists(r.Context(), kind, search) if err != nil { return err } data := s.base(r, title, nav) data.Data = map[string]any{ "Lists": lists, "Kind": kind, "Search": search, "IsBlacklist": kind == models.KindBlacklist, } s.render(w, r, "lists", data) return nil } func (s *Server) handleListCreate(w http.ResponseWriter, r *http.Request) error { if err := parseForm(r); err != nil { return err } enabled := formBool(r, "enabled") in := app.ListInput{ Kind: formString(r, "kind"), Name: formString(r, "name"), Description: formString(r, "description"), SourceURL: formString(r, "source_url"), Enabled: &enabled, } l, err := s.app.CreateDomainList(r.Context(), s.actor(r), in) if err != nil { return err } setFlash(w, r, "success", fmt.Sprintf("%s created.", l.Name)) return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", l.ID)) } func (s *Server) handleListDetail(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } list, err := s.app.DomainList(r.Context(), id) if err != nil { return err } p := newPagination(r, 100) search := formString(r, "q") entries, total, err := s.app.DomainEntries(r.Context(), id, search, p.PerPage, p.Offset) if err != nil { return err } nav := "blacklists" if list.Kind == models.KindAllowlist { nav = "allowlists" } data := s.base(r, list.Name, nav) data.Data = map[string]any{ "List": list, "Entries": entries, "Pagination": p.withTotal(total), "Search": search, } s.render(w, r, "list_detail", data) return nil } func (s *Server) handleListUpdate(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := parseForm(r); err != nil { return err } enabled := formBool(r, "enabled") in := app.ListInput{ Name: formString(r, "name"), Description: formString(r, "description"), SourceURL: formString(r, "source_url"), Enabled: &enabled, } l, err := s.app.UpdateDomainList(r.Context(), s.actor(r), id, in) if err != nil { return err } setFlash(w, r, "success", fmt.Sprintf("%s saved.", l.Name)) return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", id)) } func (s *Server) handleListToggle(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := parseForm(r); err != nil { return err } enabled := formBool(r, "enabled") if err := s.app.SetDomainListEnabled(r.Context(), s.actor(r), id, enabled); err != nil { return err } setFlash(w, r, "success", "List "+enabledWord(enabled)+".") s.redirectBack(w, r) return nil } func (s *Server) handleListDelete(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := parseForm(r); err != nil { return err } list, err := s.app.DomainList(r.Context(), id) if err != nil { return err } if err := s.app.DeleteDomainList(r.Context(), s.actor(r), id); err != nil { return err } setFlash(w, r, "success", fmt.Sprintf("%s deleted.", list.Name)) target := "/policies/blacklists" if list.Kind == models.KindAllowlist { target = "/policies/allowlists" } return s.redirect(w, r, target) } func (s *Server) handleListClear(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := parseForm(r); err != nil { return err } n, err := s.app.ClearDomains(r.Context(), s.actor(r), id) if err != nil { return err } setFlash(w, r, "success", fmt.Sprintf("Removed %s domains.", humanNumber(n))) return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", id)) } // handleListImport accepts a pasted list or an uploaded file. func (s *Server) handleListImport(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } maxMB := s.app.Settings().HTTP.MaxUploadMB if err := parseMultipart(r, 8); err != nil { return err } matchSubdomains := formBool(r, "match_subdomains") pasted := r.FormValue("content") var reader = strings.NewReader(pasted) if file, header, ferr := r.FormFile("file"); ferr == nil { defer file.Close() // The upload is streamed straight into the parser rather than being // buffered as a string: blocklists routinely run to tens of megabytes. summary, err := s.app.ImportDomains(r.Context(), s.actor(r), id, file, matchSubdomains) if err != nil { return err } s.flashImportSummary(w, r, header.Filename, summary) return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", id)) } if strings.TrimSpace(pasted) == "" { return app.Invalid("Paste a list of domains or choose a file to upload (up to %d MB).", maxMB) } summary, err := s.app.ImportDomains(r.Context(), s.actor(r), id, reader, matchSubdomains) if err != nil { return err } s.flashImportSummary(w, r, "", summary) return s.redirect(w, r, fmt.Sprintf("/policies/lists/%d", id)) } // flashImportSummary reports exactly what the import did, which is the only // way an operator can tell a 300,000 line file was handled correctly. func (s *Server) flashImportSummary(w http.ResponseWriter, r *http.Request, filename string, sum models.ImportSummary) { var b strings.Builder if filename != "" { fmt.Fprintf(&b, "Imported %s: ", filename) } else { b.WriteString("Import complete: ") } fmt.Fprintf(&b, "%s lines processed, %s domains added", humanNumber(sum.LinesProcessed), humanNumber(sum.Imported)) if sum.Duplicates > 0 { fmt.Fprintf(&b, ", %s duplicates skipped", humanNumber(sum.Duplicates)) } if sum.Invalid > 0 { fmt.Fprintf(&b, ", %s invalid entries", humanNumber(sum.Invalid)) } if sum.Ignored > 0 { fmt.Fprintf(&b, ", %s comments or blank lines ignored", humanNumber(sum.Ignored)) } b.WriteString(".") setFlash(w, r, "success", b.String()) } func (s *Server) handleListExport(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } list, err := s.app.DomainList(r.Context(), id) if err != nil { return err } filename := strings.ReplaceAll(strings.ToLower(list.Name), " ", "-") + ".txt" w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) // Streamed straight to the client: a large list must not be buffered. if _, err := s.app.ExportDomains(r.Context(), id, w); err != nil { s.log.Error("list export failed mid-stream", "list", list.Name, "error", err) } return nil } func (s *Server) handleDomainAdd(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := parseForm(r); err != nil { return err } entry, err := s.app.AddDomain(r.Context(), s.actor(r), id, formString(r, "domain"), formBool(r, "match_subdomains"), formString(r, "comment")) if err != nil { return err } setFlash(w, r, "success", fmt.Sprintf("Added %s.", entry.Domain)) s.redirectBack(w, r) return nil } func (s *Server) handleDomainDelete(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := parseForm(r); err != nil { return err } if err := s.app.DeleteDomain(r.Context(), s.actor(r), id); err != nil { return err } setFlash(w, r, "success", "Domain removed.") s.redirectBack(w, r) return nil }