Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions middleware/middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Package middleware provides HTTP middleware for transparent L402 payment handling.
//
// The middleware intercepts outgoing HTTP requests and automatically handles L402
// payment challenges (HTTP 402 responses). When a proxied request returns 402,
// the middleware pays the Lightning invoice using the configured wallet, then
// retries the request with the L402 token.
package middleware

import (
"io"
"net/http"
"net/http/httputil"
"net/url"

"github.com/sulusolutions/gol402/client"
"github.com/sulusolutions/gol402/tokenstore"
"github.com/sulusolutions/gol402/wallet"
)

// Config holds configuration for the L402 middleware.
type Config struct {
// Wallet handles Lightning invoice payments.
Wallet wallet.Wallet
// Store persists L402 tokens for reuse across requests.
Store tokenstore.Store
}

// L402 returns HTTP middleware that transparently handles L402 payment challenges.
// It wraps the next handler, intercepting responses. If the upstream returns
// HTTP 402 with a WWW-Authenticate L402 challenge, the middleware pays the invoice
// via the configured wallet and retries the request with the L402 token.
//
// The middleware follows the standard Go pattern: it takes an http.Handler and
// returns a new http.Handler.
func L402(cfg Config) func(http.Handler) http.Handler {
store := cfg.Store
if store == nil {
store = tokenstore.NewNoopStore()
}
l402Client := client.New(cfg.Wallet, store)

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract the target URL from the request.
// In proxy mode, the full URL is in r.URL or X-Target-URL header.
targetURL := r.Header.Get("X-Target-URL")
if targetURL == "" {
// Not a proxy request — pass through to next handler.
next.ServeHTTP(w, r)
return
}

parsed, err := url.Parse(targetURL)
if err != nil {
http.Error(w, "invalid X-Target-URL: "+err.Error(), http.StatusBadRequest)
return
}

// Build the upstream request.
upstreamReq, err := http.NewRequestWithContext(r.Context(), r.Method, parsed.String(), r.Body)
if err != nil {
http.Error(w, "failed to build upstream request: "+err.Error(), http.StatusInternalServerError)
return
}

// Copy relevant headers from the original request.
copyHeaders(upstreamReq.Header, r.Header)
upstreamReq.Header.Del("X-Target-URL") // Don't forward the routing header.

// Use the L402 client to make the request. It handles 402 challenges automatically.
resp, err := l402Client.Do(upstreamReq)
if err != nil {
http.Error(w, "upstream request failed: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()

// Copy the upstream response back to the client.
copyHeaders(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
})
}
}

// ReverseProxy returns a reverse proxy handler that transparently handles L402
// payment challenges when proxying requests to the given target URL.
func ReverseProxy(target *url.URL, cfg Config) http.Handler {
store := cfg.Store
if store == nil {
store = tokenstore.NewNoopStore()
}
l402Client := client.New(cfg.Wallet, store)

proxy := httputil.NewSingleHostReverseProxy(target)

// Wrap the proxy transport to use the L402 client.
proxy.Transport = &l402Transport{
client: l402Client,
targetURL: target,
}

return proxy
}

// l402Transport implements http.RoundTripper, using the L402 client
// to handle payment challenges during proxied requests.
type l402Transport struct {
client *client.Client
targetURL *url.URL
}

func (t *l402Transport) RoundTrip(req *http.Request) (*http.Response, error) {
// The reverse proxy passes requests with RequestURI set, but
// http.Client.Do() rejects such requests. Clear it before delegating.
req.RequestURI = ""
return t.client.Do(req)
}

// copyHeaders copies HTTP headers from src to dst, skipping hop-by-hop headers.
func copyHeaders(dst, src http.Header) {
hopByHop := map[string]bool{
"Connection": true,
"Keep-Alive": true,
"Proxy-Authenticate": true,
"Proxy-Authorization": true,
"Te": true,
"Trailers": true,
"Transfer-Encoding": true,
"Upgrade": true,
}

for key, values := range src {
if hopByHop[key] {
continue
}
for _, v := range values {
dst.Add(key, v)
}
}
}
Loading