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

265 lines
6.3 KiB
Go

package web
import (
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/owen/vibedns/internal/app"
)
// listen binds the management address, explaining common failures.
func listen(addr string) (net.Listener, error) {
ln, err := net.Listen("tcp", addr)
if err == nil {
return ln, nil
}
msg := err.Error()
switch {
case strings.Contains(msg, "address already in use"):
return nil, fmt.Errorf("management interface cannot bind %s: the address is already in use", addr)
case strings.Contains(msg, "permission denied"):
return nil, fmt.Errorf("management interface cannot bind %s: permission denied "+
"(ports below 1024 need root or CAP_NET_BIND_SERVICE)", addr)
default:
return nil, fmt.Errorf("management interface cannot bind %s: %w", addr, err)
}
}
func parseURL(s string) (*url.URL, error) { return url.Parse(s) }
// httpLimiter is a coarse fixed-window request counter per client address.
type httpLimiter struct {
mu sync.Mutex
windows map[string]*window
lastGC time.Time
}
type window struct {
count int
start time.Time
}
func newHTTPLimiter() *httpLimiter {
return &httpLimiter{windows: map[string]*window{}}
}
func (l *httpLimiter) allow(key string, perMinute int) bool {
if perMinute <= 0 {
return true
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
if now.Sub(l.lastGC) > 5*time.Minute {
for k, w := range l.windows {
if now.Sub(w.start) > 2*time.Minute {
delete(l.windows, k)
}
}
l.lastGC = now
}
w, ok := l.windows[key]
if !ok || now.Sub(w.start) >= time.Minute {
l.windows[key] = &window{count: 1, start: now}
return true
}
w.count++
return w.count <= perMinute
}
// --- form helpers -------------------------------------------------------
// formString reads a trimmed form value.
func formString(r *http.Request, key string) string {
return strings.TrimSpace(r.FormValue(key))
}
// formBool reads a checkbox. Boolean fields pair a hidden false value with a
// checkbox true value, so a checked field arrives as ["false", "true"]. Treat
// the field as true when any submitted value is true instead of relying on
// FormValue, which only returns the first value.
func formBool(r *http.Request, key string) bool {
if err := r.ParseForm(); err != nil {
return false
}
for _, raw := range r.Form[key] {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "on", "yes":
return true
}
}
return false
}
// formBoolPtr returns nil when the field was not submitted at all.
func formBoolPtr(r *http.Request, key string) *bool {
if err := r.ParseForm(); err != nil {
return nil
}
if _, ok := r.Form[key]; !ok {
return nil
}
v := formBool(r, key)
return &v
}
// formInt reads an integer form field, falling back to def when empty.
func formInt(r *http.Request, key string, def int) int {
raw := formString(r, key)
if raw == "" {
return def
}
v, err := strconv.Atoi(raw)
if err != nil {
return def
}
return v
}
// formUint32 reads an unsigned form field.
func formUint32(r *http.Request, key string, def uint32) uint32 {
raw := formString(r, key)
if raw == "" {
return def
}
v, err := strconv.ParseUint(raw, 10, 32)
if err != nil {
return def
}
return uint32(v)
}
// formUint32Ptr returns nil when the field is empty, which distinguishes
// "inherit the zone default" from an explicit value.
func formUint32Ptr(r *http.Request, key string) *uint32 {
raw := formString(r, key)
if raw == "" {
return nil
}
v, err := strconv.ParseUint(raw, 10, 32)
if err != nil {
return nil
}
out := uint32(v)
return &out
}
// formInt64s reads a repeated integer field, such as a set of checkboxes.
func formInt64s(r *http.Request, key string) []int64 {
var out []int64
for _, raw := range r.Form[key] {
v, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err == nil && v > 0 {
out = append(out, v)
}
}
return out
}
// parseForm parses the request body, reporting an oversized upload clearly.
func parseForm(r *http.Request) error {
if err := r.ParseForm(); err != nil {
if strings.Contains(err.Error(), "http: request body too large") {
return app.Invalid("The submitted data is larger than the configured upload limit.")
}
return app.Invalid("The form data could not be read: %v", err)
}
return nil
}
// parseMultipart parses a file upload up to the configured limit.
func parseMultipart(r *http.Request, maxMemoryMB int) error {
if maxMemoryMB <= 0 {
maxMemoryMB = 8
}
if err := r.ParseMultipartForm(int64(maxMemoryMB) * 1024 * 1024); err != nil {
if strings.Contains(err.Error(), "http: request body too large") {
return app.Invalid("The uploaded file is larger than the configured upload limit.")
}
return app.Invalid("The upload could not be read: %v", err)
}
return nil
}
// pagination computes offsets from query parameters.
type pagination struct {
Page int
PerPage int
Offset int
Total int
Pages int
HasPrev bool
HasNext bool
From int
To int
}
func newPagination(r *http.Request, defaultPerPage int) pagination {
page := formInt(r, "page", 1)
if page < 1 {
page = 1
}
per := formInt(r, "per_page", defaultPerPage)
switch {
case per < 10:
per = 10
case per > 500:
per = 500
}
return pagination{Page: page, PerPage: per, Offset: (page - 1) * per}
}
// withTotal fills in the derived fields once the row count is known.
func (p pagination) withTotal(total int) pagination {
p.Total = total
p.Pages = (total + p.PerPage - 1) / p.PerPage
if p.Pages < 1 {
p.Pages = 1
}
p.HasPrev = p.Page > 1
p.HasNext = p.Page < p.Pages
p.From = p.Offset + 1
p.To = p.Offset + p.PerPage
if p.To > total {
p.To = total
}
if total == 0 {
p.From = 0
}
return p
}
// parseInt64 parses a numeric query parameter.
func parseInt64(s string) (int64, error) {
return strconv.ParseInt(strings.TrimSpace(s), 10, 64)
}
// parseDate reads a date or datetime filter from a form field. endOfDay
// extends a bare date to 23:59:59 so a "to" filter includes that whole day.
func parseDate(s string, endOfDay bool) (time.Time, bool) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, false
}
for _, layout := range []string{"2006-01-02T15:04", "2006-01-02 15:04:05", "2006-01-02"} {
t, err := time.ParseInLocation(layout, s, time.Local)
if err != nil {
continue
}
if endOfDay && layout == "2006-01-02" {
t = t.Add(24*time.Hour - time.Second)
}
return t, true
}
return time.Time{}, false
}