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