commit 3ca46c9ea2f36ebfcae1617b0a8891291a56a35d Author: Oleh Astappiev Date: Wed Oct 16 15:57:59 2024 +0200 first commit diff --git a/.assets/icon.png b/.assets/icon.png new file mode 100644 index 0000000..cd5f556 Binary files /dev/null and b/.assets/icon.png differ diff --git a/.github/workflows/go-cross.yml b/.github/workflows/go-cross.yml new file mode 100644 index 0000000..6e2b2e4 --- /dev/null +++ b/.github/workflows/go-cross.yml @@ -0,0 +1,47 @@ +name: Go Matrix +on: [push, pull_request] + +jobs: + + cross: + name: Go + runs-on: ${{ matrix.os }} + env: + CGO_ENABLED: 0 + + strategy: + matrix: + go-version: [ 1.19, 1.x ] + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + # https://github.com/marketplace/actions/setup-go-environment + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go-version }} + + # https://github.com/marketplace/actions/checkout + - name: Checkout code + uses: actions/checkout@v2 + + # https://github.com/marketplace/actions/cache + - name: Cache Go modules + uses: actions/cache@v3 + with: + # In order: + # * Module download cache + # * Build cache (Linux) + # * Build cache (Mac) + # * Build cache (Windows) + path: | + ~/go/pkg/mod + ~/.cache/go-build + ~/Library/Caches/go-build + %LocalAppData%\go-build + key: ${{ runner.os }}-${{ matrix.go-version }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.go-version }}-go- + + - name: Test + run: go test -v -cover ./... diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..025a3d1 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,72 @@ +name: Main + +on: + push: + branches: + - master + pull_request: + +jobs: + + main: + name: Main Process + runs-on: ubuntu-latest + env: + GO_VERSION: 1.19 + GOLANGCI_LINT_VERSION: v1.50.0 + YAEGI_VERSION: v0.14.2 + CGO_ENABLED: 0 + defaults: + run: + working-directory: ${{ github.workspace }}/go/src/github.com/${{ github.repository }} + + steps: + + # https://github.com/marketplace/actions/setup-go-environment + - name: Set up Go ${{ env.GO_VERSION }} + uses: actions/setup-go@v2 + with: + go-version: ${{ env.GO_VERSION }} + + # https://github.com/marketplace/actions/checkout + - name: Check out code + uses: actions/checkout@v2 + with: + path: go/src/github.com/${{ github.repository }} + fetch-depth: 0 + + # https://github.com/marketplace/actions/cache + - name: Cache Go modules + uses: actions/cache@v3 + with: + path: ${{ github.workspace }}/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + # https://golangci-lint.run/usage/install#other-ci + - name: Install golangci-lint ${{ env.GOLANGCI_LINT_VERSION }} + run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin ${GOLANGCI_LINT_VERSION} + + - name: Install Yaegi ${{ env.YAEGI_VERSION }} + run: curl -sfL https://raw.githubusercontent.com/traefik/yaegi/master/install.sh | bash -s -- -b $(go env GOPATH)/bin ${YAEGI_VERSION} + + - name: Setup GOPATH + run: go env -w GOPATH=${{ github.workspace }}/go + + - name: Check and get dependencies + run: | + go mod tidy + git diff --exit-code go.mod + # git diff --exit-code go.sum + go mod download + go mod vendor + # git diff --exit-code ./vendor/ + + - name: Lint and Tests + run: make + + - name: Run tests with Yaegi + run: make yaegi_test + env: + GOPATH: ${{ github.workspace }}/go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41b0008 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea/ +.vscode/ +.DS_Store + +postgres/ +go.work diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..cc994d7 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,75 @@ +run: + timeout: 3m + skip-files: [] + skip-dirs: [] + +linters-settings: + govet: + enable-all: true + disable: + - fieldalignment + golint: + min-confidence: 0 + gocyclo: + min-complexity: 12 + goconst: + min-len: 5 + min-occurrences: 4 + misspell: + locale: US + funlen: + lines: -1 + statements: 50 + godox: + keywords: + - FIXME + gofumpt: + extra-rules: true + +linters: + enable-all: true + disable: + - deadcode # deprecated + - exhaustivestruct # deprecated + - golint # deprecated + - ifshort # deprecated + - interfacer # deprecated + - maligned # deprecated + - nosnakecase # deprecated + - scopelint # deprecated + - scopelint # deprecated + - structcheck # deprecated + - varcheck # deprecated + - sqlclosecheck # not relevant (SQL) + - rowserrcheck # not relevant (SQL) + - execinquery # not relevant (SQL) + - cyclop # duplicate of gocyclo + - bodyclose # Too many false positives: https://github.com/timakin/bodyclose/issues/30 + - dupl + - testpackage + - tparallel + - paralleltest + - nlreturn + - wsl + - exhaustive + - exhaustruct + - goerr113 + - wrapcheck + - ifshort + - noctx + - lll + - gomnd + - forbidigo + - varnamelen + +issues: + exclude-use-default: false + max-per-linter: 0 + max-same-issues: 0 + exclude: [] + exclude-rules: + - path: (.+)_test.go + linters: + - goconst + - funlen + - godot diff --git a/.traefik.yml b/.traefik.yml new file mode 100644 index 0000000..be53505 --- /dev/null +++ b/.traefik.yml @@ -0,0 +1,17 @@ +displayName: Umami Feeder +type: middleware +iconPath: .assets/icon.png + +import: github.com/astappiev/traefik-umami-feeder + +summary: 'Traefik plugin that sends HTTP requests to Umami as pageview events.' + +testData: + umamiHost: "" + umamiToken: "" + umamiUsername: "" + umamiPassword: "" + websites: + domain: websiteId + createNewWebsites: false + debug: false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5dfc385 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Oleh Astappiev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..04f6f05 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +.PHONY: lint test vendor clean + +export GO111MODULE=on + +default: lint test + +lint: + golangci-lint run + +test: + go test -v -cover ./... + +yaegi_test: + yaegi test -v . + +vendor: + go mod vendor + +clean: + rm -rf ./vendor \ No newline at end of file diff --git a/demo/config.yml b/demo/config.yml new file mode 100644 index 0000000..742c3f2 --- /dev/null +++ b/demo/config.yml @@ -0,0 +1,10 @@ +http: + middlewares: + umami: + plugin: + umami-feeder: + debug: true + umamiHost: http://umami:3000 + umamiUsername: "admin" + umamiPassword: "umami" + createNewWebsites: true diff --git a/demo/docker-compose.yml b/demo/docker-compose.yml new file mode 100644 index 0000000..221af77 --- /dev/null +++ b/demo/docker-compose.yml @@ -0,0 +1,70 @@ +services: + traefik: + image: traefik:3 + labels: + - "traefik.enable=true" + - "traefik.http.routers.traefik.service=api@internal" + - "traefik.http.routers.traefik.rule=Host(`localhost`)" + - "traefik.http.routers.traefik.entrypoints=traefik" + networks: + - traefik + ports: + - target: 8080 + published: 8080 + protocol: tcp + - target: 8081 + published: 8081 + protocol: tcp + volumes: + # So that Traefik can listen to the Docker events + - /var/run/docker.sock:/var/run/docker.sock + # To persist ACME data (certificates, account, etc) + - ./traefik.yml:/traefik.yml:ro + - ./config.yml:/config/config.yml:ro + # mount plugin + - ../:/plugins-local/src/github.com/astappiev/traefik-umami-feeder + + restart: unless-stopped + + whoami: + image: containous/whoami + labels: + - "traefik.enable=true" + - "traefik.http.services.whoami.loadbalancer.server.port=80" + - "traefik.http.routers.whoami.service=whoami" + - "traefik.http.routers.whoami.rule=Host(`localhost`)" + - "traefik.http.routers.whoami.entrypoints=whoami" + # middleware + - "traefik.http.routers.whoami.middlewares=umami@file" + + networks: + - traefik + + umami: + image: ghcr.io/umami-software/umami:postgresql-latest + depends_on: + - postgres + environment: + - DATABASE_URL=postgres://umami:umami@postgres:5432/umami + ports: + - target: 3000 + published: 3000 + protocol: tcp + networks: + - traefik + - umami_postgres + + postgres: + image: postgres:16 + environment: + - POSTGRES_PASSWORD=umami + - POSTGRES_DB=umami + - POSTGRES_USER=umami + networks: + - umami_postgres + volumes: + - ./postgres:/var/lib/postgresql/data + +networks: + traefik: + umami_postgres: diff --git a/demo/traefik.yml b/demo/traefik.yml new file mode 100644 index 0000000..dafaeec --- /dev/null +++ b/demo/traefik.yml @@ -0,0 +1,30 @@ +log: + level: INFO + +# accessLog: {} + +providers: + docker: + endpoint: "unix:///var/run/docker.sock" + exposedByDefault: false + network: traefik + watch: true + file: + directory: /config + watch: true + +entryPoints: + "traefik": # traefik dashboard + address: ":8080" + "whoami": # http + address: ":8081" + "umami": # umami dashboard + address: ":3000" + +api: + dashboard: true + +experimental: + localPlugins: + umami-feeder: + moduleName: "github.com/astappiev/traefik-umami-feeder" diff --git a/forwarding.go b/forwarding.go new file mode 100644 index 0000000..e282e4e --- /dev/null +++ b/forwarding.go @@ -0,0 +1,106 @@ +package traefik_umami_feeder + +// Copied source from +// https://github.com/kzmake/traefik-plugin-forward-request/blob/master/util.go + +import ( + "net" + "net/http" + "strings" +) + +const ( + xForwardedProto = "x-forwarded-proto" + xForwardedFor = "x-forwarded-for" + xForwardedHost = "x-forwarded-host" + xForwardedPort = "x-forwarded-port" + xForwardedURI = "x-forwarded-uri" + xForwardedMethod = "x-forwarded-method" + + connection = "connection" + keepAlive = "keep-alive" + proxyAuthenticate = "proxy-authenticate" + proxyAuthorization = "proxy-authorization" + te = "te" // canonicalized version of "TE" + trailers = "trailers" + transferEncoding = "transfer-Encoding" + upgrade = "upgrade" +) + +var hopHeaders = []string{ + connection, + keepAlive, + proxyAuthenticate, + proxyAuthorization, + te, // canonicalized version of "TE" + trailers, + transferEncoding, + upgrade, +} + +func copyHeaders(dst, src http.Header) { + for k, vv := range src { + for _, v := range vv { + dst.Add(k, v) + } + } +} + +func removeHeaders(headers http.Header, names ...string) { + for _, h := range names { + headers.Del(h) + } +} + +func writeXForwardedHeaders(dst http.Header, req *http.Request) { + if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil { + if values := req.Header.Values(xForwardedFor); len(values) > 0 { + clientIP = strings.Join(values, ", ") + ", " + clientIP + } + dst.Set(xForwardedFor, clientIP) + } + + xfm := req.Header.Get(xForwardedMethod) + switch { + case xfm != "": + dst.Set(xForwardedMethod, xfm) + case req.Method != "": + dst.Set(xForwardedMethod, req.Method) + default: + dst.Del(xForwardedMethod) + } + + xfp := req.Header.Get(xForwardedProto) + switch { + case xfp != "": + dst.Set(xForwardedProto, xfp) + case req.TLS != nil: + dst.Set(xForwardedProto, "https") + default: + dst.Set(xForwardedProto, "http") + } + + if xfp := req.Header.Get(xForwardedPort); xfp != "" { + dst.Set(xForwardedPort, xfp) + } + + xfh := req.Header.Get(xForwardedHost) + switch { + case xfh != "": + dst.Set(xForwardedHost, xfh) + case req.Host != "": + dst.Set(xForwardedHost, req.Host) + default: + dst.Del(xForwardedHost) + } + + xfu := req.Header.Get(xForwardedURI) + switch { + case xfu != "": + dst.Set(xForwardedURI, xfu) + case req.URL.RequestURI() != "": + dst.Set(xForwardedURI, req.URL.RequestURI()) + default: + dst.Del(xForwardedURI) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c3fb628 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/astappiev/traefik-umami-feeder + +go 1.19 diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..d9c3da0 --- /dev/null +++ b/readme.md @@ -0,0 +1,90 @@ +# Umami Feeder for Traefik + +This plugin for enables [Traefik Reverse Proxy](https://traefik.io/traefik/) to feed [Umami Analytics](https://umami.is) +with tracking events. + +It was created as an alternative to [traefik-umami-plugin](https://github.com/1cedsoda/traefik-umami-plugin) and +inspired by idea of [Plausible Feeder Traefik Plugin](https://github.com/safing/plausiblefeeder). + +## Features + +- [X] Super easy to setup, one middleware for all websites +- [X] Server Side Tracking, no need to add JavaScript to your websites +- [X] Fast and private analytics + +## Installation + +To [add this plugin to traefik](https://plugins.traefik.io/install) reference this repository as a plugin in the static +config. +The version references a git tag. + +```yaml +experimental: + plugins: + umami-feeder: + moduleName: github.com/astappiev/traefik-umami-feeder + version: v1.0.0 # replace with latest version available +``` + +```toml +[experimental.plugins.umami-feeder] + moduleName = "github.com/astappiev/traefik-umami-feeder" + version = "v1.0.0" # replace with latest version available +``` + +With the plugin installed, you can configure a middleware in a dynamic configuration such as a `config.yml` or docker +labels. + +```yaml +http: + middlewares: + my-umami-middleware: + plugin: + umami-feeder: + umamiHost: "http://umami:3000" + websites: + "example.com": "d4617504-241c-4797-8eab-5939b367b3ad" +``` + +```toml +[http.middlewares] + [http.middlewares.my-umami-middleware.plugin.umami-feeder] + umamiHost = "umami:3000" + + [http.middlewares.my-umami-middleware.plugin.umami-feeder.websites] + "example.com" = "d4617504-241c-4797-8eab-5939b367b3ad" +``` + +You have an option to give a list of domains to track (and their website IDs on Umami). \ +Or, you can give a token and the list will be fetched from Umami. For this, you need either [retrieve the token yourself](https://umami.is/docs/api/authentication), or use +username/password instead. + +After that, you need to add the middleware to a [router](https://doc.traefik.io/traefik/routing/routers/#middlewares_1). +Remember to reference the +correct [provider namespace](https://doc.traefik.io/traefik/providers/overview/#provider-namespace). + +E.g. as Docker labels: +```yaml +- "traefik.http.routers.whoami.middlewares=my-umami-middleware@file" +``` + +Or, for all routers in a static configuration: +```yaml +entryPoints: + web: + http: + middlewares: + - my-umami-middleware@file +``` + +## Configuration + +| key | default | type | description | +|---------------------|---------|----------|-------------------------------------------------------------------------------------------------------------| +| `umamiHost` | - | `string` | Umami server url, reachable from within traefik (container), e.g. `http://umami:3000` | +| `umamiToken` | - | `string` | An API Token, used to automatize work with websites, not needed if you provide `websites` | +| `umamiUsername` | - | `string` | An alternative to `umamiToken`, you can provide an username and password | +| `umamiPassword` | - | `string` | Only in combination with `umamiUsername` | +| `websites` | - | `map` | A map of hostnames and their associated Umami IDs. Can also be used to override or extend fetched websites | +| `createNewWebsites` | false | `bool` | If set to `true`, will try to create a new website on Umami, if domain not found there | +| `debug` | false | `bool` | Something doesn't work? Set to `true` to see more logs (plugins doesn't have access to Traefik's log level) | diff --git a/umami.go b/umami.go new file mode 100644 index 0000000..2b825f1 --- /dev/null +++ b/umami.go @@ -0,0 +1,171 @@ +package traefik_umami_feeder + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "time" +) + +// Config the plugin configuration. +type Config struct { + UmamiHost string `json:"umamiHost"` + // it is optional, but either UmamiToken or Websites should be set + UmamiToken string `json:"umamiToken"` + // as an alternative to UmamiToken, you can set UmamiUsername and UmamiPassword to authenticate + UmamiUsername string `json:"umamiUsername"` + UmamiPassword string `json:"umamiPassword"` + // if both UmamiToken and Websites are set, Websites will be used to override the websites in the API + Websites map[string]string `json:"websites"` + // if createNewWebsites is set to true, the plugin will create new websites in the API, UmamiToken is required + CreateNewWebsites bool `json:"createNewWebsites"` + Debug bool `json:"debug"` +} + +// CreateConfig creates the default plugin configuration. +func CreateConfig() *Config { + return &Config{ + UmamiHost: "", + UmamiToken: "", + UmamiUsername: "", + UmamiPassword: "", + Websites: map[string]string{}, + CreateNewWebsites: false, + Debug: false, + } +} + +// UmamiFeeder a UmamiFeeder plugin. +type UmamiFeeder struct { + next http.Handler + name string + debug bool + logHandler *log.Logger + + UmamiHost string + UmamiToken string + Websites map[string]string + CreateNewWebsites bool +} + +// New created a new Demo plugin. +func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { + // construct + h := &UmamiFeeder{ + next: next, + name: name, + debug: config.Debug, + logHandler: log.New(os.Stdout, "", 0), + + UmamiHost: config.UmamiHost, + UmamiToken: config.UmamiToken, + Websites: config.Websites, + CreateNewWebsites: config.CreateNewWebsites, + } + + if config.UmamiUsername != "" && config.UmamiPassword != "" { + token, err := getToken(h.UmamiHost, config.UmamiUsername, config.UmamiPassword) + if err != nil { + return nil, fmt.Errorf("failed to get token: %w", err) + } + if token == "" { + return nil, fmt.Errorf("retrieved token is empty") + } + h.trace("token received %s", token) + h.UmamiToken = token + } + + if h.UmamiHost == "" { + return nil, fmt.Errorf("`umamiHost` is not set") + } + if h.UmamiToken == "" && len(h.Websites) == 0 { + return nil, fmt.Errorf("either `umamiToken` or `websites` should be set") + } + if h.UmamiToken == "" && h.CreateNewWebsites { + return nil, fmt.Errorf("`umamiToken` is required to create new websites") + } + + if h.UmamiToken != "" { + websites, err := fetchWebsites(h.UmamiHost, h.UmamiToken) + if err != nil { + return nil, fmt.Errorf("failed to fetch websites: %w", err) + } + + for _, website := range *websites { + if _, ok := h.Websites[website.Domain]; ok { + continue + } + + h.Websites[website.Domain] = website.ID + h.trace("fetched websiteId for: %s", website.Domain) + } + h.log("websites fetched") + } + + return h, nil +} + +func (h *UmamiFeeder) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + if h.shouldBeTracked(req) { + go h.trackRequest(req) + } else { + h.trace("Tracking skipped %v", req.URL) + } + + h.next.ServeHTTP(rw, req) +} + +func (h *UmamiFeeder) shouldBeTracked(req *http.Request) bool { + if h.CreateNewWebsites { + return true + } + + hostname := parseDomainFromHost(req.Host) + if _, ok := h.Websites[hostname]; ok { + return true + } + + return false +} + +func (h *UmamiFeeder) trackRequest(req *http.Request) { + hostname := parseDomainFromHost(req.Host) + websiteId, ok := h.Websites[hostname] + if !ok { + website, err := createWebsite(h.UmamiHost, h.UmamiToken, hostname) + if err != nil { + h.log("failed to create website: " + err.Error()) + return + } + + h.Websites[website.Domain] = website.ID + websiteId = website.ID + h.trace("created website for: %s", website.Domain) + } + + sendBody, sendHeaders := buildSendBody(req, websiteId) + h.trace("sending tracking request %s with body %v %v", req.URL, sendBody, sendHeaders) + + _, err := sendRequest(h.UmamiHost+"/api/send", sendBody, sendHeaders) + if err != nil { + h.trace("failed to send tracking: " + err.Error()) + return + } +} + +func (h *UmamiFeeder) log(message string) { + if h.logHandler != nil { + time := time.Now().Format("2006-01-02T15:04:05Z") + h.logHandler.Printf("time=\"%s\" level=info msg=\"[traefik-umami-feeder] %s\"", time, message) + } +} + +// Arguments are handled in the manner of [fmt.Printf]. +func (h *UmamiFeeder) trace(format string, v ...any) { + if h.logHandler != nil && h.debug { + time := time.Now().Format("2006-01-02T15:04:05Z") + h.logHandler.Printf("time=\"%s\" level=trace msg=\"[traefik-umami-feeder] %s\"", time, fmt.Sprintf(format, v...)) + } +} diff --git a/umami_send.go b/umami_send.go new file mode 100644 index 0000000..8a3fb73 --- /dev/null +++ b/umami_send.go @@ -0,0 +1,72 @@ +package traefik_umami_feeder + +import ( + "net/http" + "regexp" + "strings" +) + +// Copied from https://github.com/1cedsoda/traefik-umami-plugin/blob/master/umami_tracking.go +// Licensed as Apache-2.0 license + +type SendPayload struct { + Website string `json:"website"` + Hostname string `json:"hostname"` + Language string `json:"language"` + Url string `json:"url"` + Referer string `json:"referer"` + Name string `json:"name"` + Data map[string]interface{} `json:"data"` +} + +type SendBody struct { + Payload SendPayload `json:"payload"` + Type string `json:"type"` +} + +func buildPayload(req *http.Request, websiteId string) SendPayload { + return SendPayload{ + Website: websiteId, + Hostname: parseDomainFromHost(req.Host), + Language: parseAcceptLanguage(req.Header.Get("Accept-Language")), + Url: req.URL.String(), + Referer: req.Referer(), + Name: "traefik", + Data: map[string]interface{}{}, + } +} + +// opts the port from the host. +func parseDomainFromHost(host string) string { + // check if the host has a port + if strings.Contains(host, ":") { + host = strings.Split(host, ":")[0] + } + return host +} + +const parseAcceptLanguagePattern = `([a-zA-Z\-]+)(?:;q=\d\.\d)?(?:,\s)?` + +var parseAcceptLanguageRegexp = regexp.MustCompile(parseAcceptLanguagePattern) + +func parseAcceptLanguage(acceptLanguage string) string { + matches := parseAcceptLanguageRegexp.FindAllStringSubmatch(acceptLanguage, -1) + if len(matches) == 0 { + return "" + } + return matches[0][1] +} + +func buildSendBody(clientReq *http.Request, websiteId string) (SendBody, http.Header) { + body := SendBody{ + Payload: buildPayload(clientReq, websiteId), + Type: "event", + } + + var headers = make(http.Header) + headers.Set("Content-Type", "application/json") + copyHeaders(headers, clientReq.Header) + removeHeaders(headers, hopHeaders...) + writeXForwardedHeaders(headers, clientReq) + return body, headers +} diff --git a/umami_token.go b/umami_token.go new file mode 100644 index 0000000..910dd25 --- /dev/null +++ b/umami_token.go @@ -0,0 +1,24 @@ +package traefik_umami_feeder + +type Auth struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type AuthResponse struct { + Token string `json:"token"` +} + +func getToken(umamiHost string, umamiUsername string, umamiPassword string) (string, error) { + var result AuthResponse + err := sendRequestAndParse(umamiHost+"/api/auth/login", Auth{ + Username: umamiUsername, + Password: umamiPassword, + }, nil, &result) + + if err != nil { + return "", err + } + + return result.Token, nil +} diff --git a/umami_utils.go b/umami_utils.go new file mode 100644 index 0000000..1af6936 --- /dev/null +++ b/umami_utils.go @@ -0,0 +1,72 @@ +package traefik_umami_feeder + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +func sendRequest(url string, body interface{}, headers http.Header) (*http.Response, error) { + var req *http.Request + var err error + + if body != nil { + bodyJson, err := json.Marshal(body) + if err != nil { + return nil, err + } + + req, err = http.NewRequestWithContext(context.Background(), http.MethodPost, url, bytes.NewReader(bodyJson)) + } else { + req, err = http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) + } + + if err != nil { + return nil, err + } + + if headers != nil { + req.Header = headers + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + client := &http.Client{} + response, err := client.Do(req) + if err != nil { + return nil, err + } + + status := response.StatusCode + if status < 200 || status >= 300 { + return nil, fmt.Errorf("request failed with status %d", status) + } + + return response, nil +} + +func sendRequestAndParse(url string, body interface{}, headers http.Header, value interface{}) error { + resp, err := sendRequest(url, body, headers) + + if err != nil { + return err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + err = json.Unmarshal(respBody, &value) + if err != nil { + return err + } + + return nil +} diff --git a/umami_websites.go b/umami_websites.go new file mode 100644 index 0000000..79a1207 --- /dev/null +++ b/umami_websites.go @@ -0,0 +1,52 @@ +package traefik_umami_feeder + +import ( + "net/http" + "time" +) + +type WebsitesResponse struct { + Data []Website `json:"data"` + Count int `json:"count"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + OrderBy string `json:"orderBy"` +} + +type Website struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Domain string `json:"domain"` + CreatedAt time.Time `json:"createdAt,omitempty"` +} + +func createWebsite(umamiHost string, umamiToken string, websiteDomain string) (*Website, error) { + var headers = make(http.Header) + headers.Set("Authorization", "Bearer "+umamiToken) + + var result Website + err := sendRequestAndParse(umamiHost+"/api/websites", Website{ + Name: websiteDomain, + Domain: websiteDomain, + }, headers, &result) + + if err != nil { + return nil, err + } + + return &result, nil +} + +func fetchWebsites(umamiHost string, umamiToken string) (*[]Website, error) { + var headers = make(http.Header) + headers.Set("Authorization", "Bearer "+umamiToken) + + var result WebsitesResponse + err := sendRequestAndParse(umamiHost+"/api/websites?pageSize=200", nil, headers, &result) + + if err != nil { + return nil, err + } + + return &result.Data, nil +}