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() }