265 lines
6.3 KiB
Go
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
|
|
}
|