initial commit
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user