package web import ( "fmt" "net/http" "strings" "github.com/owen/vibedns/internal/app" "github.com/owen/vibedns/internal/database" "github.com/owen/vibedns/internal/models" "github.com/owen/vibedns/internal/validate" "github.com/owen/vibedns/internal/zonefile" ) func (s *Server) handleZones(w http.ResponseWriter, r *http.Request) error { return s.renderZoneList(w, r, "forward", "Forward Zones", "zones") } func (s *Server) handleZonesReverse(w http.ResponseWriter, r *http.Request) error { return s.renderZoneList(w, r, "reverse", "Reverse Zones", "zones-reverse") } func (s *Server) renderZoneList(w http.ResponseWriter, r *http.Request, kind, title, nav string) error { search := formString(r, "q") zones, err := s.app.Zones(r.Context(), database.ZoneFilter{Kind: kind, Search: search}) if err != nil { return err } data := s.base(r, title, nav) data.Data = map[string]any{ "Zones": zones, "Kind": kind, "Search": search, "Reverse": kind == "reverse", } s.render(w, r, "zones", data) return nil } func (s *Server) handleZoneNew(w http.ResponseWriter, r *http.Request) error { kind := formString(r, "kind") if kind == "" { kind = "forward" } data := s.base(r, "New Zone", navForKind(kind)) data.Data = map[string]any{ "Zone": models.Zone{DefaultTTL: s.app.Settings().DNS.DefaultTTL, Enabled: true, AutoSerial: true, Refresh: 7200, Retry: 3600, Expire: 1209600, Minimum: 3600}, "Kind": kind, "IsNew": true, "FormAction": "/zones/new", } s.render(w, r, "zone_form", data) return nil } func navForKind(kind string) string { if strings.HasPrefix(kind, "reverse") { return "zones-reverse" } return "zones" } func (s *Server) handleZoneCreate(w http.ResponseWriter, r *http.Request) error { if err := parseForm(r); err != nil { return err } in := zoneInputFromForm(r) zone, err := s.app.CreateZone(r.Context(), s.actor(r), in) if err != nil { return err } setFlash(w, r, "success", fmt.Sprintf("Zone %s created.", strings.TrimSuffix(zone.Name, "."))) return s.redirect(w, r, fmt.Sprintf("/zones/%d", zone.ID)) } func zoneInputFromForm(r *http.Request) app.ZoneInput { enabled := formBool(r, "enabled") autoSerial := formBool(r, "auto_serial") in := app.ZoneInput{ Name: formString(r, "name"), Kind: formString(r, "kind"), CIDR: formString(r, "cidr"), Description: formString(r, "description"), Enabled: &enabled, DefaultTTL: formUint32(r, "default_ttl", 0), PrimaryNS: formString(r, "primary_ns"), AdminEmail: formString(r, "admin_email"), Refresh: formUint32(r, "refresh", 0), Retry: formUint32(r, "retry", 0), Expire: formUint32(r, "expire", 0), Minimum: formUint32(r, "minimum", 0), AutoSerial: &autoSerial, } // A serial is only taken from the form when the operator asked to override // it, so a normal save never rewinds an automatically managed serial. if formBool(r, "override_serial") { in.Serial = formUint32Ptr(r, "serial") } return in } func (s *Server) handleZoneEdit(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } zone, err := s.app.Zone(r.Context(), id) if err != nil { return err } data := s.base(r, "Edit "+strings.TrimSuffix(zone.Name, "."), navForKind(string(zone.Kind))) data.Data = map[string]any{ "Zone": zone, "Kind": string(zone.Kind), "IsNew": false, "FormAction": fmt.Sprintf("/zones/%d/edit", id), } s.render(w, r, "zone_form", data) return nil } func (s *Server) handleZoneUpdate(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 } zone, err := s.app.UpdateZone(r.Context(), s.actor(r), id, zoneInputFromForm(r)) if err != nil { return err } setFlash(w, r, "success", fmt.Sprintf("Zone %s saved.", strings.TrimSuffix(zone.Name, "."))) return s.redirect(w, r, fmt.Sprintf("/zones/%d", zone.ID)) } func (s *Server) handleZoneToggle(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.SetZoneEnabled(r.Context(), s.actor(r), id, enabled); err != nil { return err } state := "disabled" if enabled { state = "enabled" } setFlash(w, r, "success", "Zone "+state+".") s.redirectBack(w, r) return nil } func (s *Server) handleZoneDelete(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 } zone, err := s.app.Zone(r.Context(), id) if err != nil { return err } if err := s.app.DeleteZone(r.Context(), s.actor(r), id); err != nil { return err } setFlash(w, r, "success", fmt.Sprintf("Zone %s and its records were deleted.", strings.TrimSuffix(zone.Name, "."))) target := "/zones" if zone.Kind != models.ZoneForward { target = "/zones/reverse" } return s.redirect(w, r, target) } func (s *Server) handleZoneClone(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 } clone, err := s.app.CloneZone(r.Context(), s.actor(r), id, formString(r, "name"), formString(r, "description")) if err != nil { return err } setFlash(w, r, "success", fmt.Sprintf("Zone cloned to %s.", strings.TrimSuffix(clone.Name, "."))) return s.redirect(w, r, fmt.Sprintf("/zones/%d", clone.ID)) } func (s *Server) handleZoneExport(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } zone, body, err := s.app.ExportZoneFile(r.Context(), id) if err != nil { return err } filename := zonefile.SuggestFilename(zone.Name) w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) w.Header().Set("Content-Length", fmt.Sprint(len(body))) _, _ = w.Write(body) return nil } func (s *Server) handleZoneImport(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } if err := parseMultipart(r, s.app.Settings().HTTP.MaxUploadMB); err != nil { return err } mode := app.ImportMode(formString(r, "mode")) body := formString(r, "content") var reader = strings.NewReader(body) if file, header, ferr := r.FormFile("file"); ferr == nil { defer file.Close() buf := new(strings.Builder) if _, err := copyLimited(buf, file, int64(s.app.Settings().HTTP.MaxUploadMB)*1024*1024); err != nil { return app.Invalid("The uploaded file %q could not be read.", header.Filename) } reader = strings.NewReader(buf.String()) } else if strings.TrimSpace(body) == "" { return app.Invalid("Choose a zone file to upload, or paste its contents.") } result, err := s.app.ImportZoneFile(r.Context(), s.actor(r), id, reader, mode) if err != nil { return err } msg := fmt.Sprintf("Imported %d records into %s.", result.Summary.RecordsParsed, strings.TrimSuffix(result.Zone.Name, ".")) if result.Summary.Skipped > 0 { msg += fmt.Sprintf(" %d entries were skipped.", result.Summary.Skipped) } setFlash(w, r, "success", msg) return s.redirect(w, r, fmt.Sprintf("/zones/%d", id)) } // handleZoneRecords renders one zone's record table. func (s *Server) handleZoneRecords(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { return err } zone, err := s.app.Zone(r.Context(), id) if err != nil { return err } p := newPagination(r, 100) f := database.RecordFilter{ ZoneID: id, Search: formString(r, "q"), Type: formString(r, "type"), Enabled: formString(r, "status"), Limit: p.PerPage, Offset: p.Offset, } records, total, err := s.app.Records(r.Context(), f) if err != nil { return err } types, _ := s.app.RecordTypesInUse(r.Context(), id) data := s.base(r, strings.TrimSuffix(zone.Name, "."), navForKind(string(zone.Kind))) data.Data = map[string]any{ "Zone": zone, "Records": records, "Pagination": p.withTotal(total), "Filter": f, "TypesInUse": types, "RecordTypes": s.app.RecordTypes(), "Problems": s.app.Runtime.ZoneProblems(id), "CommonTypes": commonTypes(s.app.RecordTypes()), } s.render(w, r, "records", data) return nil } func commonTypes(all []validate.TypeInfo) []validate.TypeInfo { var out []validate.TypeInfo for _, t := range all { if t.Common { out = append(out, t) } } return out } // handleRecordsAll is the cross-zone record search. func (s *Server) handleRecordsAll(w http.ResponseWriter, r *http.Request) error { p := newPagination(r, 50) f := database.RecordFilter{ Search: formString(r, "q"), Type: formString(r, "type"), Enabled: formString(r, "status"), Limit: p.PerPage, Offset: p.Offset, } if v := formString(r, "zone"); v != "" { if id, err := parseInt64(v); err == nil { f.ZoneID = id } } records, total, err := s.app.Records(r.Context(), f) if err != nil { return err } zones, _ := s.app.Zones(r.Context(), database.ZoneFilter{}) types, _ := s.app.RecordTypesInUse(r.Context(), 0) data := s.base(r, "Records", "records") data.Data = map[string]any{ "Records": records, "Pagination": p.withTotal(total), "Filter": f, "Zones": zones, "TypesInUse": types, } s.render(w, r, "records_all", data) return nil }