commit 3a40b0f22c0a99fe3abde5d63cd61bd45cad4688 Author: Carl Kittelberger Date: Wed Aug 17 03:34:18 2022 +0200 Initial commit. diff --git a/cmd/test/main.go b/cmd/test/main.go new file mode 100644 index 0000000..5a02fde --- /dev/null +++ b/cmd/test/main.go @@ -0,0 +1,213 @@ +package main + +import ( + "context" + "flag" + "io" + "mime" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "git.icedream.tech/icedream/oneandone-billing-mailer/pkg/api" + "git.icedream.tech/icedream/oneandone-billing-mailer/pkg/appcontext" + "git.icedream.tech/icedream/oneandone-billing-mailer/pkg/auth" + "git.icedream.tech/icedream/oneandone-billing-mailer/pkg/auth/authcache" + "git.icedream.tech/icedream/oneandone-billing-mailer/pkg/auth/authcache/fileauthcache" + "git.icedream.tech/icedream/oneandone-billing-mailer/pkg/backend" + "git.icedream.tech/icedream/oneandone-billing-mailer/pkg/environment" + "github.com/go-logr/logr" + "github.com/go-logr/zapr" + "go.uber.org/zap" + "golang.org/x/oauth2" +) + +const ( + // webviewDebug toggles the debug mode in the Webview library. + webviewDebug = false + + // apiDebug toggles the debug mode in the OpenAPI client. + apiDebug = false +) + +// dateFormat is the format used to pass dates to the 1&1 API +const dateFormat = "2006-01-02" + +// main is the main entrypoint. +func main() { + ctx := context.Background() + + // load logger + zapLogger, err := zap.NewDevelopment() + if err != nil { + os.Stderr.WriteString("Could not initialize zap logger\n") + os.Exit(1) + return + } + generalLog := zapr.NewLogger(zapLogger) + ctx = logr.NewContext(ctx, generalLog) + log := generalLog.WithName("app") + + // parse cmdline + flag.Parse() + + // initialize oauth stuff for API + oauth, err := auth.NewOAuthFromEnvironment(ctx) + if err != nil { + log.Error(err, "Failed to initialize central login helper") + return + } + + // Try and get an initial token first + tokenCache := fileauthcache.NewFileAuthCache("token.json") + token, err := tokenCache.Fetch() + if err != nil { + log.Error(err, "Failed to fetch token from cache") + return + } + if token == nil { + // We need to fetch an initial token + tokenSource, err := oauth.TokenSource(nil, requestUserConsentFunc(ctx)) + if err != nil { + log.Error(err, "Failed to set up OAuth token source") + return + } + cacheTokenSource := authcache.NewCacheTokenSource(ctx, tokenCache, tokenSource) + token, err = cacheTokenSource.Token() + if err != nil { + log.Error(err, "Failed to fetch initial token") + return + } + } + + // Set up new token source only for refreshing + tokenSource, err := oauth.TokenSource(token, nil) + if err != nil { + log.Error(err, "Failed to set up OAuth token source") + return + } + cacheTokenSource := authcache.NewCacheTokenSource(ctx, tokenCache, tokenSource) + + reuseTokenSource := oauth2.ReuseTokenSource(token, cacheTokenSource) + + client := oauth2.NewClient(ctx, reuseTokenSource) + client.Transport = backend.BackendRequestTransport(client.Transport) + + // MSSA invoice requests from here + + appctx, err := appcontext.GetEnvironmentContext() + if err != nil { + log.Error(err, "Failed to fetch app env context") + return + } + mssaBaseURL, err := appctx.MSSA.BaseURL() + if err != nil { + log.Error(err, "Failed to parse MSSA base URL") + return + } + + auth := context.WithValue(ctx, api.ContextOAuth2, reuseTokenSource) + + apiConfig := api.NewConfiguration() + apiConfig.HTTPClient = client + apiConfig.Debug = apiDebug + apiConfig.UserAgent = environment.UserAgent() + + apiClient := api.NewAPIClient(apiConfig) + + now := time.Now() + thisMonthStartDate := now. + // go to first day of this month + AddDate(0, 0, 1-now.Day()) + nextMonthStartDate := thisMonthStartDate. + // add 1 month + AddDate(0, 1, 0) + + apiReq := apiClient.InvoicesApi.GetInvoicesInInterval(auth, + thisMonthStartDate.Format(dateFormat), + nextMonthStartDate.Format(dateFormat)). + PerPage(1) + apiResp, resp, err := apiReq.Page(1).Execute() + if err != nil { + panic(err) + } + if resp.StatusCode != http.StatusOK { + panic(resp.Status) + } + for _, invoice := range apiResp.GetInvoice() { + // skip entries without invoice document + doc := invoice.Links.InvoiceDocument + if doc == nil { + continue + } + + ext := ".pdf" + if doc.Type != nil { + exts, err := mime.ExtensionsByType(*doc.Type) + if err != nil { + log.V(1).Error(err, "Failed to fetch extensions for type", + "type", *doc.Type) + } else if len(exts) > 0 { + ext = exts[0] + } + } + // make sure extension starts with dot + if !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + + title := "Rechnung" + if doc.Title != nil { + title = *doc.Title + } + fileName := filepath.Clean(title) + ext + + u, err := url.Parse(doc.Href) + if err != nil { + log.V(1).Error(err, "Failed to parse invoice document hyperlink reference, skipping", + "url", doc.Href) + continue + } + u = mssaBaseURL.ResolveReference(u) + + // open PDF on server + resp, err := apiClient.GetConfig().HTTPClient.Get(u.String()) + if err != nil { + log.Error(err, "Invoice PDF download request failed, skipping", + "fileName", fileName) + continue + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + log.Error(err, "Unexpected status code for invoice PDF download request, skipping", + "fileName", fileName, + "statusCode", resp.StatusCode) + continue + } + + // write PDF to file as we download + f, err := os.Create(fileName) + if err != nil { + resp.Body.Close() + log.Error(err, "Could not create invoice PDF", + "fileName", fileName) + continue + } + _, err = io.Copy(f, resp.Body) + resp.Body.Close() + if err != nil { + os.Remove(f.Name()) + log.Error(err, "Could not write to invoice PDF", + "fileName", fileName) + } + err = f.Close() + if err != nil { + os.Remove(f.Name()) + log.Error(err, "Could not complete writing to invoice PDF", + "fileName", fileName) + } + } +} diff --git a/cmd/test/oauth.go b/cmd/test/oauth.go new file mode 100644 index 0000000..649b953 --- /dev/null +++ b/cmd/test/oauth.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "errors" + "sync" + + "github.com/go-logr/logr" + "github.com/webview/webview" + "golang.org/x/oauth2/authhandler" +) + +func requestUserConsentFunc(ctx context.Context) authhandler.AuthorizationHandler { + return func(originalWebUrl string) (string, string, error) { + return requestUserConsent(ctx, originalWebUrl) + } +} + +func requestUserConsent(ctx context.Context, originalWebUrl string) (code string, state string, err error) { + log := logr.FromContextOrDiscard(ctx).WithName("requestUserConsent") + + log.Info("Asking for user consent", + "url", originalWebUrl) + + proxyServer, err := NewOneAndOneLoginProxy(ctx, "localhost:0") + if err != nil { + log.Error(err, "Failed to set up local 1&1 login proxy") + return "", "", nil + } + go proxyServer.Listen() + defer proxyServer.Teardown() + + webUrl, err := proxyServer.ConvertURLString(originalWebUrl) + if err != nil { + log.Error(err, "Failed to convert oauth init URL to proxy URL") + return "", "", nil + } + + ctx, cancelFunc := context.WithCancel(ctx) + + wg := new(sync.WaitGroup) + + w := webview.New(webviewDebug) + defer w.Destroy() + w.SetTitle("1&1 Login") + w.SetSize(480, 480, webview.HintNone) + w.Navigate(webUrl.String()) + + wg.Add(1) + go func() { + defer wg.Done() + + doneC := ctx.Done() + + select { + case values := <-proxyServer.ResponseCodeC(): + code = values.Get("code") + state = values.Get("state") + log.V(1).Info("Got login response", + "code", code, + "state", state) + w.Terminate() + case <-doneC: + return + } + }() + w.Run() + + cancelFunc() + wg.Wait() + + // Did webview get terminated before we got a code? + // That's the user canceling login. + if code == "" { + return "", "", errors.New("user canceled login") + } + + return code, state, nil +} diff --git a/cmd/test/server.go b/cmd/test/server.go new file mode 100644 index 0000000..ac9791e --- /dev/null +++ b/cmd/test/server.go @@ -0,0 +1,298 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "io/ioutil" + "net" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + + "github.com/go-logr/logr" + "golang.org/x/net/publicsuffix" +) + +type OneAndOneLoginProxy struct { + httpClient *http.Client + listener net.Listener + cookieJar http.CookieJar + responseCodeC chan url.Values + logger logr.Logger +} + +func NewOneAndOneLoginProxy( + ctx context.Context, + listenAddr string, +) (*OneAndOneLoginProxy, error) { + // create temporary cookie jar for login flow + cookieJar, err := cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + if err != nil { + return nil, err + } + + // channel to return response code on + responseCodeC := make(chan url.Values, 1) + + p := &OneAndOneLoginProxy{ + cookieJar: cookieJar, + httpClient: &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + logger: logr.FromContextOrDiscard(ctx).WithName("OneAndOneLoginProxy"), + responseCodeC: responseCodeC, + } + if err := p.setupListener(listenAddr); err != nil { + return nil, err + } + return p, nil +} + +func (p *OneAndOneLoginProxy) ResponseCodeC() <-chan url.Values { + return p.responseCodeC +} + +func (p *OneAndOneLoginProxy) Host() string { + addr := p.listener.Addr().(*net.TCPAddr) + return net.JoinHostPort( + addr.IP.String(), + fmt.Sprintf("%d", addr.Port)) +} + +func (p *OneAndOneLoginProxy) setupListener(listenAddr string) error { + if len(listenAddr) == 0 { + // any random local port + listenAddr = "localhost:0" + } + listener, err := net.Listen("tcp", listenAddr) + if err != nil { + return err + } + p.listener = listener + + p.logger.V(1).Info("Proxy listener set up", + "addr", p.listener.Addr()) + + return nil +} + +func (p *OneAndOneLoginProxy) Teardown() error { + var err error + p.logger.V(2).Info("Teardown() called on proxy") + if p.listener != nil { + p.logger.V(1).Info("Tearing down proxy") + err = p.listener.Close() + p.listener = nil + } + return err +} + +func (p *OneAndOneLoginProxy) Listen() error { + p.logger.V(2).Info("Listen() called on proxy") + p.logger.V(1).Info("Proxy now going to listen") + return http.Serve(p.listener, p) +} + +func convertToProxyPath(u *url.URL) string { + // Enforce path at least containing / + if !strings.HasPrefix(u.Path, "/") { + u.Path = "/" + u.Path + } + + return fmt.Sprintf("%s/%s%s", u.Scheme, u.Host, u.Path) +} + +func (p *OneAndOneLoginProxy) ConvertURL(u *url.URL) *url.URL { + return &url.URL{ + Scheme: "http", + Host: p.Host(), + Path: convertToProxyPath(u), + RawQuery: u.RawQuery, + RawFragment: u.RawFragment, + } +} + +func (p *OneAndOneLoginProxy) ConvertURLString(uStr string) (*url.URL, error) { + u, err := url.Parse(uStr) + if err != nil { + return nil, err + } + return p.ConvertURL(u), nil +} + +func (p *OneAndOneLoginProxy) decodeURL(u *url.URL) (proxyURL *url.URL) { + reqURI := strings.TrimPrefix(u.Path, "/") + reqURIParts := strings.SplitN(reqURI, "/", 3) + proxyScheme := strings.ToLower(reqURIParts[0]) + if reqURIParts[0] != "http" && reqURIParts[0] != "https" { + // assume this is a URL relative to https://account.1und1.de + proxyURL = &url.URL{ + Scheme: "https", + Host: "account.1und1.de", + Path: u.Path, + RawQuery: u.RawQuery, + } + } else { + // process as absolute URL + proxyURL = &url.URL{ + Scheme: proxyScheme, + RawQuery: u.RawQuery, + } + if len(reqURIParts) >= 2 { + proxyURL.Host = reqURIParts[1] + } + if len(reqURIParts) > 2 { + proxyURL.Path = reqURIParts[2] + } + } + return proxyURL +} + +func (p *OneAndOneLoginProxy) ServeHTTP(w http.ResponseWriter, req *http.Request) { + // create a new url from the raw RequestURI sent by the client + // /{scheme}/{hostport}/{path/with/slashes} => {scheme}://{hostport}/{path/with/slashes} + proxyURL := p.decodeURL(req.URL) + + // buffer body before forwarding or processing it further + body, err := ioutil.ReadAll(req.Body) + if err != nil { + p.logger.V(1).Error(err, "Failed to read request body", + "method", req.Method, + "url", req.URL) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // you can reassign the body if you need to parse it as multipart + req.Body = ioutil.NopCloser(bytes.NewReader(body)) + + // assemble real request + proxyReq, err := http.NewRequest( + req.Method, + proxyURL.String(), + bytes.NewReader(body)) + if err != nil { + p.logger.V(1).Error(err, "Failed to create proxy request", + "method", req.Method, + "url", req.URL, + "proxyURL", proxyURL.String()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // set headers removing everything that we set ourselves + proxyReq.Header = make(http.Header) + for key, values := range req.Header { + for i, value := range values { + switch http.CanonicalHeaderKey(key) { + case "Cookie": // we add this from our own jar + continue + case "Origin", "Referer": + u, err := url.Parse(value) + if err == nil { + realURL := p.decodeURL(u) + values[i] = realURL.String() + } + } + proxyReq.Header.Add(key, value) + } + } + + // load cookies from jar to request + for _, c := range p.cookieJar.Cookies(proxyReq.URL) { + // p.logger.V(1).Info("Proxy sends cookie", "cookie", c.Name) + proxyReq.AddCookie(c) + } + + resp, err := p.httpClient.Do(proxyReq) + if err != nil { + p.logger.V(1).Error(err, "Failed to do real request", + "method", req.Method, + "url", req.URL, + "proxyURL", proxyURL.String()) + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + // store cookies from response to jar + p.cookieJar.SetCookies(proxyReq.URL, resp.Cookies()) + + // copy response headers + respondNoContent := false + for key, values := range resp.Header { + for _, value := range values { + switch http.CanonicalHeaderKey(key) { + case "Location": + u, err := url.Parse(value) + if err != nil { + break + } + if u.IsAbs() { + switch strings.ToLower(u.Scheme) { + case "com.oneandone.controlcenter.android": + // there's our oauth code, let's catch it before + // anything bad happens + p.logger.V(1).Info("Proxy found oauth response", "url", u.String()) + q := u.Query() + + // Special case for empty state where server + // inexplicably responds with "state=null" which causes + // 3LO state check in go oauth library to fail + if q.Get("state") == "null" { + q.Set("state", "") + } + + p.responseCodeC <- q + respondNoContent = true + default: + u = p.ConvertURL(u) + value = u.String() + } + } + case "Set-Cookie": + // skip these headers as we already handled that ourselves + continue + } + w.Header().Add(key, value) + } + } + + if respondNoContent { + p.logger.V(1).Info("Serving incoming request without content", + "method", req.Method, + "url", req.URL, + "proxyURL", proxyURL.String()) + w.WriteHeader(http.StatusNoContent) + return + } + + // copy response body + p.logger.V(1).Info("Serving incoming request", + "method", req.Method, + "url", req.URL, + "proxyURL", proxyURL.String(), + "statusCode", resp.StatusCode, + "status", resp.Status) + w.WriteHeader(resp.StatusCode) + contentType := resp.Header.Get("Content-Type") + switch { + case resp.Uncompressed && + (strings.Contains(contentType, "application/json") || + strings.Contains(contentType, "text/plain") || + strings.Contains(contentType, "text/html")): + buf := new(bytes.Buffer) + io.Copy(buf, resp.Body) + bufStr := buf.String() + io.Copy(w, strings.NewReader(bufStr)) + default: + io.Copy(w, resp.Body) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..814fd94 --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module git.icedream.tech/icedream/oneandone-billing-mailer + +go 1.19 + +require ( + github.com/go-logr/logr v1.2.3 + github.com/go-logr/zapr v1.2.3 + go.uber.org/zap v1.19.0 +) + +require ( + github.com/golang/protobuf v1.5.2 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.6.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.28.0 // indirect +) + +require ( + github.com/google/uuid v1.3.0 + github.com/webview/webview v0.0.0-20220729131735-25e7f41b8bbf + golang.org/x/net v0.0.0-20220812174116-3211cb980234 + golang.org/x/oauth2 v0.0.0-20220808172628-8227340efae7 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..88c1a6a --- /dev/null +++ b/go.sum @@ -0,0 +1,75 @@ +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/zapr v1.2.3 h1:a9vnzlIBPQBBkeaR9IuMUfmVOrQlkoC4YfPoFkX3T7A= +github.com/go-logr/zapr v1.2.3/go.mod h1:eIauM6P8qSvTw5o2ez6UEAfGjQKrxQTl5EoK+Qa2oG4= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/webview/webview v0.0.0-20220729131735-25e7f41b8bbf h1:xGxamNQiDXDlPzCumBdLWflZDwruHkwJ1iORcpJmblk= +github.com/webview/webview v0.0.0-20220729131735-25e7f41b8bbf/go.mod h1:rpXAuuHgyEJb6kXcXldlkOjU6y4x+YcASKKXJNUhh0Y= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.19.0 h1:mZQZefskPPCMIBCSEH0v2/iUqqLrYtaeqwD6FUGUnFE= +go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20220812174116-3211cb980234 h1:RDqmgfe7SvlMWoqC3xwQ2blLO3fcWcxMa3eBLRdRW7E= +golang.org/x/net v0.0.0-20220812174116-3211cb980234/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/oauth2 v0.0.0-20220808172628-8227340efae7 h1:dtndE8FcEta75/4kHF3AbpuWzV6f1LjnLrM4pe2SZrw= +golang.org/x/oauth2 v0.0.0-20220808172628-8227340efae7/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11 h1:Yq9t9jnGoR+dBuitxdo9l6Q7xh/zOyNnYUtDKaQ3x0E= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/api/.openapi-generator-ignore b/pkg/api/.openapi-generator-ignore new file mode 100644 index 0000000..3f10faa --- /dev/null +++ b/pkg/api/.openapi-generator-ignore @@ -0,0 +1,5 @@ +go.mod +go.sum +*.sh +.travis.* +.gitignore diff --git a/pkg/api/gen_client.go b/pkg/api/gen_client.go new file mode 100644 index 0000000..e273a69 --- /dev/null +++ b/pkg/api/gen_client.go @@ -0,0 +1,3 @@ +package api + +//go:generate openapi-generator-cli generate -i ../../spec/openapi.yml --package-name $GOPACKAGE -g go --enable-post-process-file -p isGoSubmodule=true -p generateInterfaces=true -p withGoCodegenComment=true diff --git a/pkg/appcontext/app_context.go b/pkg/appcontext/app_context.go new file mode 100644 index 0000000..e1d4024 --- /dev/null +++ b/pkg/appcontext/app_context.go @@ -0,0 +1,94 @@ +package appcontext + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + "strconv" + "time" + + "git.icedream.tech/icedream/oneandone-billing-mailer/pkg/backend" + "git.icedream.tech/icedream/oneandone-billing-mailer/pkg/environment" + mssahttp "git.icedream.tech/icedream/oneandone-billing-mailer/pkg/mssa/http" +) + +type Entry struct { + BaseURI string `json:"baseURI"` +} + +func (e *Entry) BaseURL() (*url.URL, error) { + return url.Parse(e.BaseURI) +} + +type Config struct { + RefreshTokenRenewInterval time.Duration `json:"refreshTokenRenewInterval"` +} + +func mustMakeURL(u string) *url.URL { + uo, err := url.Parse(u) + if err != nil { + panic(err) + } + return uo +} + +var ( + appcontext_uri_live = mustMakeURL("https://auth-proxy.1und1.de/appcontext/appcontext.json") +) + +var instance *AppContext + +func GetEnvironmentContext() (*AppContext, error) { + if instance == nil { + err := retrieveAppContextInternal() + if err != nil { + return nil, err + } + } + + return instance, nil +} + +type AppContext struct { + MSSA Entry `json:"MSSA"` + CentralLogin Entry `json:"CentralLogin"` + CentralLoginApp Entry `json:"CentralLoginApp"` + ChatServiceRest Entry `json:"ChatServiceRest"` + ChatServiceWebsocket Entry `json:"ChatServiceWebsocket"` + ChatServiceWebsocket2 Entry `json:"ChatServiceWebsocket2"` + DOPAS Entry `json:"DOPAS"` + Config Config `json:"Config"` + Links map[string]string `json:"Links"` +} + +func retrieveAppContextInternal() error { + u := appcontext_uri_live + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return err + } + req.Header.Add("Accept", "application/json") + req.Header.Add("User-agent", environment.UserAgent()) + backend.AddRequestHeadersForProxy(req) + client := mssahttp.GetDefaultClient() + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return errors.New("unexpected error code: " + strconv.Itoa(resp.StatusCode)) + } + + appContext := new(AppContext) + err = json.NewDecoder(resp.Body).Decode(appContext) + if err != nil { + return err + } + + instance = appContext + return nil +} diff --git a/pkg/auth/authcache/cache.go b/pkg/auth/authcache/cache.go new file mode 100644 index 0000000..3caf8a5 --- /dev/null +++ b/pkg/auth/authcache/cache.go @@ -0,0 +1,8 @@ +package authcache + +import "golang.org/x/oauth2" + +type Cache interface { + Fetch() (*oauth2.Token, error) + Persist(*oauth2.Token) error +} diff --git a/pkg/auth/authcache/fileauthcache/fileauthcache.go b/pkg/auth/authcache/fileauthcache/fileauthcache.go new file mode 100644 index 0000000..6220ba3 --- /dev/null +++ b/pkg/auth/authcache/fileauthcache/fileauthcache.go @@ -0,0 +1,67 @@ +package fileauthcache + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + + "git.icedream.tech/icedream/oneandone-billing-mailer/pkg/auth/authcache" + "golang.org/x/oauth2" +) + +type fileAuthCache struct { + filePath string + cacheLock sync.Mutex +} + +func NewFileAuthCache(filePath string) authcache.Cache { + return &fileAuthCache{ + filePath: filePath, + } +} + +func (ts *fileAuthCache) Persist(token *oauth2.Token) (err error) { + ts.cacheLock.Lock() + defer ts.cacheLock.Unlock() + + f, err := os.CreateTemp(filepath.Dir(ts.filePath), "") + if err != nil { + return err + } + + if err = f.Chmod(0o600); err != nil { + f.Close() + return err + } + + if err = json.NewEncoder(f).Encode(token); err != nil { + f.Close() + return err + } + + if err = f.Close(); err != nil { + return err + } + + err = os.Rename(f.Name(), ts.filePath) + return err +} + +func (ts *fileAuthCache) Fetch() (token *oauth2.Token, err error) { + f, err := os.OpenFile(ts.filePath, os.O_RDONLY, 0o600) + if os.IsNotExist(err) { + // Treat as no existing token found + return nil, nil + } + if err != nil { + return nil, err + } + + decodedToken := new(oauth2.Token) + if err = json.NewDecoder(f).Decode(decodedToken); err != nil { + return nil, err + } + + return decodedToken, nil +} diff --git a/pkg/auth/authcache/tokensource.go b/pkg/auth/authcache/tokensource.go new file mode 100644 index 0000000..840b6a6 --- /dev/null +++ b/pkg/auth/authcache/tokensource.go @@ -0,0 +1,71 @@ +package authcache + +import ( + "context" + + "github.com/go-logr/logr" + "golang.org/x/oauth2" +) + +type cacheTokenSource struct { + cache Cache + + tokenSource oauth2.TokenSource + ctx context.Context +} + +func NewCacheTokenSource(ctx context.Context, cache Cache, tokenSource oauth2.TokenSource) oauth2.TokenSource { + // Avoid dupe wrapping + if rts, ok := tokenSource.(*cacheTokenSource); ok { + return rts + } + + return &cacheTokenSource{ + cache: cache, + tokenSource: tokenSource, + ctx: ctx, + } +} + +func (ts *cacheTokenSource) Token() (*oauth2.Token, error) { + log := logr.FromContextOrDiscard(ts.ctx).WithName("cacheTokenSource") + + token, err := ts.cache.Fetch() + if err != nil { + log.V(1).Info("Failed to fetch token", "err", err.Error()) + return nil, err + } + oldToken := token + + // Do we have a cached token? + if token == nil || token.RefreshToken == "" { + // We are starting fresh + log.V(1).Info("No existing token, requesting fresh one") + newToken, err := ts.tokenSource.Token() + if err != nil { + log.V(1).Info("Failed to request fresh token", "err", err.Error()) + return nil, err + } + token = newToken + } else if !token.Valid() /*|| token.Expiry.Add(-5*time.Minute).Before(time.Now())*/ { + // Our existing token has expired, refresh + log.V(1).Info("Existing token has expired or is about to expire, requesting refresh") + newToken, err := ts.tokenSource.Token() + if err != nil { + log.V(1).Info("Failed to refresh token", "err", err.Error()) + return nil, err + } + token = newToken + } + + // Persist token if changed/fresh + if oldToken == nil || oldToken.AccessToken != token.AccessToken { + log.V(1).Info("Persisting new token") + if err := ts.cache.Persist(token); err != nil { + return nil, err + } + } + + log.V(1).Info("Returning token") + return token, nil +} diff --git a/pkg/auth/central_login_helper.go b/pkg/auth/central_login_helper.go new file mode 100644 index 0000000..e1a80b3 --- /dev/null +++ b/pkg/auth/central_login_helper.go @@ -0,0 +1,43 @@ +package auth + +import ( + "encoding/base64" + "net/http" + "net/url" + "time" + + _ "github.com/paulrosania/go-charset/data" + + "git.icedream.tech/icedream/oneandone-billing-mailer/pkg/appcontext" + "git.icedream.tech/icedream/oneandone-billing-mailer/pkg/environment" +) + +const ( + client_id = "access.mobile.app" + client_secret_live = "Yi79C2YG2CJYH!U9TXPRpXRciyhApr" + redirect_url = "com.oneandone.controlcenter.android://oauth" +) + +var urlEncodingWithoutPadding = base64.URLEncoding.WithPadding(base64.NoPadding) + +type CentralLoginHelper struct { + baseUri *url.URL +} + +func NewCentralLoginHelper() (*CentralLoginHelper, error) { + ctx, err := appcontext.GetEnvironmentContext() + if err != nil { + return nil, err + } + baseURL, err := ctx.CentralLogin.BaseURL() + if err != nil { + return nil, err + } + return &CentralLoginHelper{ + baseUri: baseURL, + }, nil +} + +func clientSecretForEnvironment() string { + return client_secret_live +} diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go new file mode 100644 index 0000000..0dc9ef2 --- /dev/null +++ b/pkg/auth/oauth.go @@ -0,0 +1,114 @@ +package auth + +import ( + "context" + "crypto/sha256" + "net/url" + + "git.icedream.tech/icedream/oneandone-billing-mailer/pkg/appcontext" + "github.com/go-logr/logr" + "github.com/google/uuid" + "golang.org/x/oauth2" + "golang.org/x/oauth2/authhandler" +) + +type OAuth struct { + config *oauth2.Config + ctx context.Context + pkceKey string +} + +func NewOAuth(ctx context.Context, clientID string, clientSecret string, baseUri *url.URL) *OAuth { + endpoint := oauth2.Endpoint{ + AuthURL: baseUri.ResolveReference(&url.URL{Path: "auth/init"}).String(), + TokenURL: baseUri.ResolveReference(&url.URL{Path: "token"}).String(), + } + + config := &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + Endpoint: endpoint, + RedirectURL: redirect_url, + Scopes: []string{"openid", "account_otk"}, + } + + return &OAuth{ + ctx: ctx, + config: config, + } +} + +func NewOAuthFromEnvironment(ctx context.Context) (*OAuth, error) { + appctx, err := appcontext.GetEnvironmentContext() + if err != nil { + return nil, err + } + baseURL, err := appctx.CentralLogin.BaseURL() + if err != nil { + return nil, err + } + + clientID := client_id + clientSecret := clientSecretForEnvironment() + + return NewOAuth(ctx, clientID, clientSecret, baseURL), nil +} + +func (h *OAuth) randomKey() (string, error) { + pkceKeyUUID, err := uuid.NewRandom() + if err != nil { + return "", err + } + pkceKey := pkceKeyUUID.String() + return pkceKey, nil +} + +func (h *OAuth) TokenSource(t *oauth2.Token, authHandler authhandler.AuthorizationHandler) (oauth2.TokenSource, error) { + log := logr.FromContextOrDiscard(h.ctx).WithName("OAuth") + + log.V(2).Info("TokenSource was called") + + if t != nil && len(t.RefreshToken) > 0 { + // Use a refresher based on given token's refresh token instead + return h.config.TokenSource(h.ctx, t), nil + } + + // Generate state. + // + // Note that the Control Center app does not even pass a state at all, but + // the oauth server properly implements handling it. + //state, err := h.randomKey() + //if err != nil { + // return nil, err + //} + state := "" + + // Generate pkce key (which becomes our code verifier) + if len(h.pkceKey) == 0 { + pkceKey, err := h.randomKey() + if err != nil { + return nil, err + } + h.pkceKey = pkceKey + } + + // Generate pkce key digest (which becomes our code challenge) + messageDigest := sha256.New() + messageDigest.Write([]byte(h.pkceKey)) + messageDigestResult := messageDigest.Sum(nil) + messageDigestResultB64 := urlEncodingWithoutPadding.EncodeToString(messageDigestResult) + pkce := &authhandler.PKCEParams{ + Challenge: messageDigestResultB64, + ChallengeMethod: "S256", + Verifier: h.pkceKey, + } + + tok := authhandler.TokenSourceWithPKCE( + context.Background(), + h.config, + state, + authHandler, + pkce) + + return tok, nil +} diff --git a/pkg/backend/request.go b/pkg/backend/request.go new file mode 100644 index 0000000..e0aef4a --- /dev/null +++ b/pkg/backend/request.go @@ -0,0 +1,29 @@ +package backend + +import "net/http" + +func AddRequestHeadersForProxy(req *http.Request) { + req.Header.Set("Connection", "Keep-Alive") + req.Header.Set("Accept-Language", "de-DE") + req.Header.Set("Referer", "https://control-center-app.1und1.de") +} + +type backendRequestTransport struct { + underlyingTransport http.RoundTripper +} + +func (t *backendRequestTransport) RoundTrip(req *http.Request) (*http.Response, error) { + nreq := new(http.Request) + *nreq = *req + AddRequestHeadersForProxy(nreq) + return t.underlyingTransport.RoundTrip(nreq) +} + +func BackendRequestTransport(t http.RoundTripper) http.RoundTripper { + if t == nil { + t = http.DefaultTransport + } + return &backendRequestTransport{ + underlyingTransport: t, + } +} diff --git a/pkg/environment/environment.go b/pkg/environment/environment.go new file mode 100644 index 0000000..87f42e3 --- /dev/null +++ b/pkg/environment/environment.go @@ -0,0 +1,23 @@ +package environment + +import "fmt" + +type Environment int + +const ( + LIVE +) + +var ( + CurrentEnvironment = LIVE + versionName = "" + versionRelease = "" +) + +const ( + USER_AGENT_TEMPLATE = "cc-app/%s Android/%s" +) + +func UserAgent() string { + return fmt.Sprintf(USER_AGENT_TEMPLATE, versionName, versionRelease) +} diff --git a/pkg/mssa/http/connection_helper.go b/pkg/mssa/http/connection_helper.go new file mode 100644 index 0000000..1419e02 --- /dev/null +++ b/pkg/mssa/http/connection_helper.go @@ -0,0 +1,30 @@ +package mssahttp + +import ( + "net/http" + "time" + + "git.icedream.tech/icedream/oneandone-billing-mailer/pkg/environment" +) + +const ( + connection_timeout = 30 * time.Second +) + +type uaSetterTransport struct{} + +func (t *uaSetterTransport) RoundTrip(req *http.Request) (*http.Response, error) { + actualReq := new(http.Request) + *actualReq = *req + actualReq.Header.Set("User-agent", environment.UserAgent()) + return http.DefaultTransport.RoundTrip(actualReq) +} + +var defaultClient = &http.Client{ + Timeout: connection_timeout, + Transport: &uaSetterTransport{}, +} + +func GetDefaultClient() *http.Client { + return defaultClient +} diff --git a/spec/openapi.yml b/spec/openapi.yml new file mode 100644 index 0000000..81ee66b --- /dev/null +++ b/spec/openapi.yml @@ -0,0 +1,213 @@ +openapi: 3.0.3 + +info: + title: 1&1 Authenticated API + version: "1.0.0" + +servers: + - url: https://auth-proxy.1und1.de/ + description: Live + +tags: + - name: Invoices + description: Invoice related requests + +components: + parameters: + page: + name: page + in: query + schema: + type: integer + per_page: + name: per_page + in: query + schema: + type: integer + interval_from: + name: interval_from + description: The start of the date range for which to list invoices. + in: path + required: true + schema: + type: string + format: date + interval_to: + name: interval_to + description: The end of the date range for which to list invoices. + in: path + required: true + schema: + type: string + format: date + schemas: + Link: + type: object + description: Represents a link. + required: + - href + properties: + href: + type: string + description: Hyperlink reference to the linked resource. + title: + type: string + description: Title for the linked resource. + type: + type: string + description: MIME type of the linke resource. + format: mime + Invoice: + type: object + description: Represents a customer invoice. + properties: + links: + description: Linked resources. + type: object + properties: + self: + $ref: "#/components/schemas/Link" + detail: + $ref: "#/components/schemas/Link" + faq: + $ref: "#/components/schemas/Link" + contract: + $ref: "#/components/schemas/Link" + invoice-document: + $ref: "#/components/schemas/Link" + type: + description: Invoice type. + type: string + enum: + - SINGLE + invoiceId: + description: Invoice ID. + type: string + parts: + description: Invoice parts. + type: array + items: + type: object + required: + - id + - contractId + - prouct + - gross + - net + - currency + - invoicePositions + properties: + id: + description: Invoice part ID. + type: integer + format: int64 + contractId: + description: Contract ID. + type: integer + format: int64 + product: + description: Product name. + type: string + gross: + description: Gross price. + type: number + net: + description: Net price. + type: number + currency: + description: 3 letter currency code as defined by ISO-4217 + type: string + format: iso-4217 + example: EUR + # invoicePositions: + # type: array + # items: + # type: any + responses: + Error: + description: Error. + content: + text/plain: + schema: + type: string + example: 401 Unauthorized + InvoiceList: + description: List of invoices. + content: + application/json: + schema: + type: object + required: + - links + - invoice + properties: + links: + description: Linked resources. + type: object + properties: + self: + $ref: "#/components/schemas/Link" + next: + $ref: "#/components/schemas/Link" + prev: + $ref: "#/components/schemas/Link" + aggregations: + $ref: "#/components/schemas/Link" + contractaggregations: + $ref: "#/components/schemas/Link" + balances: + $ref: "#/components/schemas/Link" + invoice: + type: array + description: List of found invoices. + items: + $ref: "#/components/schemas/Invoice" + securitySchemes: + oauth1and1: + type: oauth2 + description: | + This API uses 3-legged OAuth. + flows: + authorizationCode: + authorizationUrl: https://auth.1und1.de/1.0/oauth/auth/init + tokenUrl: https://auth.1und1.de/1.0/oauth/token + scopes: + openid: openid + account_otk: Account OTK + +security: + - oauth1and1: + - openid + - account_otk + +paths: + /mssa/invoices: + get: + operationId: getInvoices + summary: Gets list of customer invoices + tags: + - Invoices + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/per_page" + responses: + default: + $ref: "#/components/responses/Error" + "200": + $ref: "#/components/responses/InvoiceList" + /mssa/invoices;interval={interval_from}_{interval_to}: + get: + operationId: getInvoicesInInterval + summary: Get list of customer invoices for given interval. + tags: + - Invoices + parameters: + - $ref: "#/components/parameters/interval_from" + - $ref: "#/components/parameters/interval_to" + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/per_page" + responses: + default: + $ref: "#/components/responses/Error" + "200": + $ref: "#/components/responses/InvoiceList"