Files
2026-08-16 21:18:45 -05:00

337 lines
9.3 KiB
Go

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
}