mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-04 00:02:05 +00:00
112 lines
2.9 KiB
Go
112 lines
2.9 KiB
Go
package formserver
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// redirectCacheTTL bounds how long a redirect change takes to show; misses are cached the same.
|
|
const (
|
|
redirectCacheTTL = time.Minute
|
|
redirectCacheMax = 4096
|
|
)
|
|
|
|
type redirectEntry struct {
|
|
target string
|
|
expires time.Time
|
|
}
|
|
|
|
// redirects answers "is this host a sending domain's verified root redirect",
|
|
// so a form is never served under a domain that only redirects.
|
|
type redirects struct {
|
|
client *backendClient
|
|
mu sync.Mutex
|
|
cache map[string]redirectEntry
|
|
}
|
|
|
|
func newRedirects(c *backendClient) *redirects {
|
|
return &redirects{client: c, cache: map[string]redirectEntry{}}
|
|
}
|
|
|
|
func (r *redirects) lookup(ctx context.Context, host string) (string, bool) {
|
|
r.mu.Lock()
|
|
if e, ok := r.cache[host]; ok && time.Now().Before(e.expires) {
|
|
r.mu.Unlock()
|
|
return e.target, e.target != ""
|
|
}
|
|
r.mu.Unlock()
|
|
|
|
target := ""
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.client.base+"/api/v1/internal/domain-redirects/"+url.PathEscape(host), nil)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
resp, err := r.client.do(req)
|
|
if err != nil {
|
|
// Unknown is not a redirect; the form is served as it would have been.
|
|
return "", false
|
|
}
|
|
defer resp.Body.Close()
|
|
switch resp.StatusCode {
|
|
case http.StatusOK:
|
|
var body struct {
|
|
TargetURL string `json:"target_url"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<10)).Decode(&body); err == nil && safeTarget(body.TargetURL) {
|
|
target = body.TargetURL
|
|
}
|
|
case http.StatusNotFound:
|
|
default:
|
|
return "", false
|
|
}
|
|
|
|
r.mu.Lock()
|
|
if len(r.cache) >= redirectCacheMax {
|
|
r.cache = map[string]redirectEntry{}
|
|
}
|
|
r.cache[host] = redirectEntry{target: target, expires: time.Now().Add(redirectCacheTTL)}
|
|
r.mu.Unlock()
|
|
return target, target != ""
|
|
}
|
|
|
|
// safeTarget accepts only an absolute http(s) address.
|
|
func safeTarget(t string) bool {
|
|
u, err := url.Parse(t)
|
|
return err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" && !strings.ContainsAny(t, "\r\n ")
|
|
}
|
|
|
|
// requestHost is the Host header without port or trailing dot, lower-cased.
|
|
func requestHost(raw string) string {
|
|
h := strings.ToLower(strings.TrimSpace(raw))
|
|
if host, _, err := net.SplitHostPort(h); err == nil {
|
|
h = host
|
|
}
|
|
return strings.TrimSuffix(h, ".")
|
|
}
|
|
|
|
// domainRedirect sends any request on a verified redirect domain to its target,
|
|
// before a form page or submission route can answer under that domain.
|
|
func (s *Server) domainRedirect(c *gin.Context) {
|
|
host := requestHost(c.Request.Host)
|
|
if host == "" || !strings.Contains(host, ".") || c.Request.URL.Path == "/health" {
|
|
c.Next()
|
|
return
|
|
}
|
|
if target, ok := s.redirects.lookup(c.Request.Context(), host); ok {
|
|
c.Header("Cache-Control", "public, max-age=300")
|
|
c.Redirect(http.StatusFound, target)
|
|
c.Abort()
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|