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