diff --git a/go/proxy/config_local.go b/go/proxy/config_local.go new file mode 100644 index 0000000..392e6e1 --- /dev/null +++ b/go/proxy/config_local.go @@ -0,0 +1,9 @@ +//go:build !prod + +package main + +import "log/slog" + +func init() { + slog.SetLogLoggerLevel(slog.LevelDebug) +} diff --git a/go/proxy/consts_local.go b/go/proxy/consts_local.go deleted file mode 100644 index 1c77c4f..0000000 --- a/go/proxy/consts_local.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build !prod - -package main - -const AllowOrigin = "*" diff --git a/go/proxy/consts_prod.go b/go/proxy/consts_prod.go deleted file mode 100644 index beb2818..0000000 --- a/go/proxy/consts_prod.go +++ /dev/null @@ -1,5 +0,0 @@ -//go:build prod - -package main - -const AllowOrigin = "https://explore.flights" diff --git a/go/proxy/headers_local.go b/go/proxy/headers_local.go new file mode 100644 index 0000000..773a0a1 --- /dev/null +++ b/go/proxy/headers_local.go @@ -0,0 +1,13 @@ +//go:build !prod + +package main + +import "net/http" + +func addAccessControlHeaders(h http.Header) { + h.Set("Access-Control-Allow-Origin", "*") + h.Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") + h.Set("Access-Control-Allow-Headers", "*") + h.Set("Access-Control-Allow-Credentials", "true") + h.Set("Access-Control-Max-Age", "86400") +} diff --git a/go/proxy/headers_prod.go b/go/proxy/headers_prod.go new file mode 100644 index 0000000..f4b3ba3 --- /dev/null +++ b/go/proxy/headers_prod.go @@ -0,0 +1,13 @@ +//go:build prod + +package main + +import "net/http" + +func addAccessControlHeaders(h http.Header) { + h.Set("Access-Control-Allow-Origin", "*") + h.Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") + h.Set("Access-Control-Allow-Headers", "*") + h.Set("Access-Control-Allow-Credentials", "true") + h.Set("Access-Control-Max-Age", "86400") +} diff --git a/go/proxy/main.go b/go/proxy/main.go index 1f3a84b..13dbb77 100644 --- a/go/proxy/main.go +++ b/go/proxy/main.go @@ -3,28 +3,23 @@ package main import ( "context" "errors" - "io" "log/slog" + "net" "net/http" "os" "os/signal" - "strings" "syscall" - "time" ) -func addAccessControlHeaders(h http.Header) { - h.Set("Access-Control-Allow-Origin", AllowOrigin) - h.Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") - h.Set("Access-Control-Allow-Headers", "*") - h.Set("Access-Control-Allow-Credentials", "true") - h.Set("Access-Control-Max-Age", "86400") -} - func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGINT) defer stop() + p, err := NewProxy("/milesandmore") + if err != nil { + panic(err) + } + mux := http.NewServeMux() mux.HandleFunc("OPTIONS /", func(w http.ResponseWriter, req *http.Request) { addAccessControlHeaders(w.Header()) @@ -38,78 +33,36 @@ func main() { _, _ = w.Write([]byte("github.com/explore-flights/monorepo/go/proxy")) }) - mux.HandleFunc("POST /milesandmore/", func(w http.ResponseWriter, req *http.Request) { - ctx, cancel := context.WithTimeout(req.Context(), time.Second*15) - defer cancel() - - if req.Body != nil { - defer req.Body.Close() - } - - outurl := "https://api.miles-and-more.com" - outurl += strings.TrimPrefix(req.URL.EscapedPath(), "/milesandmore") - if req.URL.RawQuery != "" { - outurl += "?" - outurl += req.URL.RawQuery - } - - outreq, err := http.NewRequestWithContext(ctx, req.Method, outurl, req.Body) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(err.Error())) - return - } - - for k, v := range req.Header { - if strings.EqualFold(k, "user-agent") || strings.EqualFold(k, "accept") || strings.EqualFold(k, "x-api-key") { - outreq.Header[k] = v - } - } - - outreq.Header.Set("Origin", "https://www.miles-and-more.com") - outreq.Header.Set("Referer", "https://www.miles-and-more.com/") - - proxyresp, err := http.DefaultClient.Do(outreq) - if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - w.WriteHeader(http.StatusRequestTimeout) - } else { - w.WriteHeader(http.StatusBadGateway) - } + mux.Handle("POST /milesandmore/", p) - _, _ = w.Write([]byte(err.Error())) - return - } - - defer proxyresp.Body.Close() - - if contentType := proxyresp.Header.Get("Content-Type"); contentType != "" { - w.Header().Set("Content-Type", contentType) - } - - addAccessControlHeaders(w.Header()) - - w.WriteHeader(proxyresp.StatusCode) - _, _ = io.Copy(w, proxyresp.Body) - }) - - if err := run(ctx, &http.Server{Addr: "127.0.0.1:8090", Handler: mux}); err != nil { + if err = run(ctx, mux); err != nil { panic(err) } + + slog.Info("proxy stopped") } -func run(ctx context.Context, srv *http.Server) error { +func run(ctx context.Context, handler http.Handler) error { + const addr = "127.0.0.1:8090" + ctx, cancel := context.WithCancel(ctx) defer cancel() + l, err := net.Listen("tcp", addr) + if err != nil { + return err + } + + slog.Info("proxy ready to accept connections", slog.String("addr", addr)) + go func() { <-ctx.Done() - if err := srv.Shutdown(context.Background()); err != nil { + if err := l.Close(); err != nil { slog.Error("error shutting down the http server", slog.String("err", err.Error())) } }() - if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + if err := http.Serve(l, handler); err != nil && !errors.Is(err, net.ErrClosed) { return err } diff --git a/go/proxy/proxy.go b/go/proxy/proxy.go new file mode 100644 index 0000000..ddb5259 --- /dev/null +++ b/go/proxy/proxy.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/cookiejar" + "strings" + "sync" + "sync/atomic" + "time" +) + +type Proxy struct { + prefix string + httpClient *http.Client + init *atomic.Bool + mtx *sync.Mutex +} + +func NewProxy(prefix string) (*Proxy, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, err + } + + httpClient := &http.Client{ + Jar: jar, + } + + return &Proxy{ + prefix: prefix, + httpClient: httpClient, + init: new(atomic.Bool), + mtx: new(sync.Mutex), + }, nil +} + +func (p *Proxy) ensureInit(ctx context.Context, inReq *http.Request) error { + if p.init.Load() { + return nil + } + + req, err := p.prepareRequest(ctx, inReq, http.MethodGet, "https://www.miles-and-more.com/de/de/spend/flights/flight-award.html", nil) + if err != nil { + return err + } + + resp, err := p.doRequest(req) + if err != nil { + return err + } + + _ = resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + p.init.Store(true) + slog.Info("proxy initialized") + return nil +} + +func (p *Proxy) prepareRequest(ctx context.Context, inReq *http.Request, method, url string, body io.Reader) (*http.Request, error) { + outReq, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, err + } + + if inReq != nil { + proxyHeaders := []string{ + "user-agent", + "accept", + "accept-encoding", + "accept-language", + "content-type", + "content-length", + "cache-control", + "pragma", + "x-api-key", + "rtw", + } + + for _, h := range proxyHeaders { + value := inReq.Header.Get(h) + if value != "" { + outReq.Header.Set(h, value) + } + } + + outReq.URL.RawQuery = inReq.URL.RawQuery + } + + outReq.Header.Set("Origin", "https://www.miles-and-more.com") + outReq.Header.Set("Referer", "https://www.miles-and-more.com/") + + if outReq.Header.Get("User-Agent") == "" { + outReq.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + } + + return outReq, nil +} + +func (p *Proxy) doRequest(r *http.Request) (*http.Response, error) { + slog.Debug( + "sending proxy request", + slog.String("method", r.Method), + slog.String("url", r.URL.String()), + slog.Any("headers", r.Header), + ) + + resp, err := p.httpClient.Do(r) + if err == nil { + slog.Debug("proxy request succeeded", slog.Int("status", resp.StatusCode)) + } else { + slog.Debug("proxy request failed", slog.String("err", err.Error())) + } + + return resp, err +} + +func (p *Proxy) ServeHTTP(w http.ResponseWriter, inReq *http.Request) { + p.mtx.Lock() + defer p.mtx.Unlock() + + ctx, cancel := context.WithTimeout(inReq.Context(), time.Second*15) + defer cancel() + + err := p.ensureInit(ctx, inReq) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(err.Error())) + return + } + + outUrl := "https://api.miles-and-more.com" + outUrl += strings.TrimPrefix(inReq.URL.EscapedPath(), p.prefix) + + outReq, err := p.prepareRequest(ctx, inReq, inReq.Method, outUrl, inReq.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(err.Error())) + return + } + + proxyResp, err := p.doRequest(outReq) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + w.WriteHeader(http.StatusRequestTimeout) + } else { + w.WriteHeader(http.StatusBadGateway) + } + + _, _ = w.Write([]byte(err.Error())) + return + } + + defer proxyResp.Body.Close() + + proxyHeaders := []string{ + "content-type", + "content-length", + "content-encoding", + "date", + } + + for _, h := range proxyHeaders { + value := proxyResp.Header.Get(h) + if value != "" { + w.Header().Set(h, value) + } + } + + addAccessControlHeaders(w.Header()) + + w.WriteHeader(proxyResp.StatusCode) + _, _ = io.Copy(w, proxyResp.Body) +}