mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 16:02:48 +00:00
221 lines
6.3 KiB
Go
221 lines
6.3 KiB
Go
package formserver
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/warmbly/warmbly/internal/formwire"
|
|
)
|
|
|
|
var errFormNotFound = errors.New("formserver: form not found")
|
|
|
|
const (
|
|
// Short positive TTL: an unpublish takes effect within it, and one busy
|
|
// form costs the backend at most a few requests a minute.
|
|
formCacheTTL = 15 * time.Second
|
|
formNegCacheTTL = 30 * time.Second
|
|
// formCacheMax bounds the map against public-id spray; real instances
|
|
// hold a few hundred forms at most.
|
|
formCacheMax = 4096
|
|
)
|
|
|
|
// backendClient is the forms service's only dependency: the backend's
|
|
// internal API, authenticated with the same bearer token the workers and the
|
|
// tracking service use.
|
|
type backendClient struct {
|
|
base string
|
|
token string
|
|
http *http.Client
|
|
|
|
mu sync.Mutex
|
|
cache map[string]cacheEntry
|
|
}
|
|
|
|
// cacheEntry with a nil form is a negative entry: the id is known missing.
|
|
type cacheEntry struct {
|
|
form *formwire.PublicForm
|
|
expires time.Time
|
|
}
|
|
|
|
func newBackendClient(base, token string) *backendClient {
|
|
return &backendClient{
|
|
base: base,
|
|
token: token,
|
|
http: &http.Client{Timeout: 10 * time.Second},
|
|
cache: map[string]cacheEntry{},
|
|
}
|
|
}
|
|
|
|
func (b *backendClient) formURL(publicID, suffix string) string {
|
|
return b.base + "/api/v1/internal/forms/" + url.PathEscape(publicID) + suffix
|
|
}
|
|
|
|
func (b *backendClient) do(req *http.Request) (*http.Response, error) {
|
|
req.Header.Set("Authorization", "Bearer "+b.token)
|
|
return b.http.Do(req)
|
|
}
|
|
|
|
func (b *backendClient) cached(publicID string) (cacheEntry, bool) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
e, ok := b.cache[publicID]
|
|
if !ok || time.Now().After(e.expires) {
|
|
return cacheEntry{}, false
|
|
}
|
|
return e, true
|
|
}
|
|
|
|
func (b *backendClient) store(publicID string, f *formwire.PublicForm, ttl time.Duration) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if len(b.cache) >= formCacheMax {
|
|
now := time.Now()
|
|
for k, e := range b.cache {
|
|
if now.After(e.expires) {
|
|
delete(b.cache, k)
|
|
}
|
|
}
|
|
// Still full after sweeping means an id spray; dropping the window
|
|
// costs a few extra backend hits, holding it costs unbounded memory.
|
|
if len(b.cache) >= formCacheMax {
|
|
b.cache = map[string]cacheEntry{}
|
|
}
|
|
}
|
|
b.cache[publicID] = cacheEntry{form: f, expires: time.Now().Add(ttl)}
|
|
}
|
|
|
|
// Form resolves a published form, TTL-cached. errFormNotFound for unknown or
|
|
// unpublished ids; any other error means the backend was unreachable.
|
|
func (b *backendClient) Form(ctx context.Context, publicID string) (*formwire.PublicForm, error) {
|
|
if e, ok := b.cached(publicID); ok {
|
|
if e.form == nil {
|
|
return nil, errFormNotFound
|
|
}
|
|
return e.form, nil
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.formURL(publicID, ""), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := b.do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
switch resp.StatusCode {
|
|
case http.StatusOK:
|
|
var f formwire.PublicForm
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&f); err != nil {
|
|
return nil, err
|
|
}
|
|
b.store(publicID, &f, formCacheTTL)
|
|
return &f, nil
|
|
case http.StatusNotFound:
|
|
b.store(publicID, nil, formNegCacheTTL)
|
|
return nil, errFormNotFound
|
|
default:
|
|
return nil, fmt.Errorf("formserver: backend returned %d for form fetch", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// submitOutcome is the backend's verdict on one submission. Exactly one of
|
|
// Result, Rejected or NotFound is meaningful.
|
|
type submitOutcome struct {
|
|
Result *formwire.SubmitResult
|
|
Rejected string
|
|
NotFound bool
|
|
}
|
|
|
|
// Submit forwards a visitor's answers. An error return means the backend was
|
|
// unreachable, never that the submission was invalid.
|
|
func (b *backendClient) Submit(ctx context.Context, publicID string, in *formwire.SubmitRequest) (*submitOutcome, error) {
|
|
body, err := json.Marshal(in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.formURL(publicID, "/submissions"), bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := b.do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
switch resp.StatusCode {
|
|
case http.StatusOK:
|
|
var res formwire.SubmitResult
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&res); err != nil {
|
|
return nil, err
|
|
}
|
|
return &submitOutcome{Result: &res}, nil
|
|
case http.StatusNotFound:
|
|
return &submitOutcome{NotFound: true}, nil
|
|
case http.StatusBadRequest:
|
|
var rej formwire.SubmitError
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&rej); err != nil || rej.Message == "" {
|
|
rej.Message = "The submission could not be processed."
|
|
}
|
|
return &submitOutcome{Rejected: rej.Message}, nil
|
|
default:
|
|
return nil, fmt.Errorf("formserver: backend returned %d for submit", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// FormWithToken resolves a form together with its ?t= prefill. Deliberately
|
|
// uncached: per-contact prefill must never be served to another visitor, and
|
|
// the render-token gate plus the event budget bound the extra backend hits.
|
|
func (b *backendClient) FormWithToken(ctx context.Context, publicID, linkToken string) (*formwire.PublicForm, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.formURL(publicID, "?t="+url.QueryEscape(linkToken)), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := b.do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
switch resp.StatusCode {
|
|
case http.StatusOK:
|
|
var f formwire.PublicForm
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&f); err != nil {
|
|
return nil, err
|
|
}
|
|
return &f, nil
|
|
case http.StatusNotFound:
|
|
b.store(publicID, nil, formNegCacheTTL)
|
|
return nil, errFormNotFound
|
|
default:
|
|
return nil, fmt.Errorf("formserver: backend returned %d for form fetch", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// RecordEvent forwards one funnel event; best-effort, the page never waits.
|
|
func (b *backendClient) RecordEvent(publicID string, ev *formwire.EventRequest) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
body, err := json.Marshal(ev)
|
|
if err != nil {
|
|
return
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.formURL(publicID, "/events"), bytes.NewReader(body))
|
|
if err != nil {
|
|
return
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := b.do(req)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = resp.Body.Close()
|
|
}
|