diff --git a/umami.go b/umami.go index 3f3252b..6ee1659 100644 --- a/umami.go +++ b/umami.go @@ -22,33 +22,111 @@ func init() { httpcaddyfile.RegisterHandlerDirective("umami", parseCaddyfile) } -// A Caddy module which sends visitor information to Umami's Events REST API endpoint. +// http.handlers.umami is a module for Caddy which sends HTTP request information to [Umami]'s Events REST API endpoint as page view events. +// The data is sent directly from the web server to Umami's [/api/send endpoint] without requiring you to include any client-side JavaScript on your website. +// The visitor's IP is set via the X-Forwarded-For header or the header specified with client_ip_header, +// one of which should be [set as CLIENT_IP_HEADER in Umami]. +// +// # Minimal example (Caddyfile) +// +// umami { +// event_endpoint https://umami.example.com/api/send +// website_uuid your-website-uuid +// } +// +// # Complete example (Caddyfile) +// +// umami { +// event_endpoint https://umami.example.com/api/send +// website_uuid your-website-uuid +// allowed_extensions "" .htm .html .php +// client_ip_header X-Real-IP +// report_all_resources +// trusted_ip_header X-Forwarded-For +// cookie_resolution umami_resolution +// device_detection +// cookie_consent umami_consent disable_all +// static_metadata { +// server node1 +// } +// } +// +// [/api/send endpoint]: https://umami.is/docs/sending-stats +// [set as CLIENT_IP_HEADER in Umami]: https://umami.is/docs/environment-variables +// [Umami]: https://umami.is type Umami struct { - // The address of your Umami installation's send API endpoint. + // The address of your Umami installation's send API endpoint, usually ending in /api/send. + // + // # Example (Caddyfile) + // event_endpoint https://umami.example.com/api/send EventEndpoint string `json:"event_endpoint"` - // The UUID of the website you want to track. + // The UUID of the website you are tracking, from the Umami control panel. + // + // # Example (Caddyfile) + // website_uuid 0cdf6f5b-9b3b-4815-9d22-8cb4d6132781 WebsiteUUID string `json:"website_uuid"` // A map of page path extensions that should be reported to Umami. You must include the leading dot. + // Include "" if you want to track URLs which don't end in a file extension, such as directories. + // + // # Defaults + // - "" + // - ".htm" + // - ".html" + // - ".php" + // # Example (Caddyfile) + // allowed_extensions "" .html AllowedExtensions []string `json:"allowed_extensions,omitempty"` - // The header to send the client IP address to Umami with. + // An HTTP header to send the client IP address to Umami with, alongside the X-Forwarded-For header (which is always sent). + // + // # Example (Caddyfile) + // client_ip_header X-Real-IP ClientIPHeader string `json:"client_ip_header,omitempty"` - // Enables reporting of all resources (ignoring extension checks). + // Enables reporting of all resources (ignoring extension checks). Overrides allowed_extensions. + // + // # Example (Caddyfile) + // report_all_resources ReportAllResources bool `json:"report_all_resources,omitempty"` // The header to use to get the client IP address from, behind a trusted reverse proxy. + // The IP in this header will be sent to Umami via the X-Forwarded-For header and the Client IP Header (if specified). + // + // # Example (Caddyfile) + // trusted_ip_header X-Forwarded-For TrustedIPHeader string `json:"trusted_ip_header,omitempty"` - // A map of cookie-based consent settings. Only the first value in the map is utilized currently. - CookieConsent []CookieConsent `json:"cookie_consent,omitempty"` // The name of the cookie that stores the visitor's screen resolution. + // It is your responsibility to set this cookie with client-side JavaScript (not provided). + // If this cookie is not set, device type will just be reported as unknown. + // If included and a name is not set, the default cookie name is umami_resolution. + // + // # Example (Caddyfile) + // cookie_resolution umami_resolution CookieResolution string `json:"cookie_resolution,omitempty"` // Enable rudimentary device detection based on Sec-CH-UA-Mobile and Sec-CH-UA-Platform headers. + // Note that this is not a replacement for a proper device detection library. + // A screen resolution set via a cookie (configured separately) will always take precedence over this. + // + // # Example (Caddyfile) + // device_detection DeviceDetection bool `json:"device_detection,omitempty"` + // A map of cookie-based consent settings. Only the first value in the map is utilized currently. + // Specify the name of a cookie, then: + // You can set the behavior to "disable_all" to disable sending analytics if the cookie value is "false", + // or "path_only" to send analytic data without client information (IP, user agent, etc.) if the cookie value is "false". + // + // # Example (Caddyfile) + // cookie_consent umami_consent disable_all + CookieConsent []CookieConsent `json:"cookie_consent,omitempty"` // Optional static metadata to include with each event via query string. + // You can include multiple key value pairs, each will be appended as a separate query string. + // + // # Example (Caddyfile) + // static_metadata { + // server node1 + // } StaticMetadata []StaticMetadata `json:"static_metadata,omitempty"` logger *zap.Logger } -// Cookie-based consent settings. type CookieConsent struct { // The name of the cookie that stores the consent setting. Name string `json:"name,omitempty"` @@ -58,7 +136,6 @@ type CookieConsent struct { Behavior string `json:"behavior,omitempty"` } -// Optional static metadata to include with each event via query string. type StaticMetadata struct { // The key of the metadata. Key string `json:"key,omitempty"` @@ -84,6 +161,7 @@ func (p Umami) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp. return err } + // Check if analytics should be performed based on cookie consent settings. p.logger.Debug("Check if analytics should be performed:", zap.Int("allowed", p.GetAllowed(r))) if p.GetAllowed(r) == 0 { return nil @@ -187,6 +265,7 @@ func (p Umami) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp. req.Header.Set("User-Agent", "Mozilla/5.0 (Unknown) Browser/1.0 (Anonymous Request)") } + // Set client IP address in headers. req.Header.Set("X-Forwarded-For", visitorIP) if p.ClientIPHeader != "" { req.Header.Set(p.ClientIPHeader, visitorIP) @@ -196,6 +275,7 @@ func (p Umami) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp. p.logger.Debug("User-Agent:", zap.String("User-Agent", req.UserAgent())) p.logger.Debug("Body:", zap.Any("visitorInfo", visitorInfo)) + // Send visitor info to Umami API. resp, err := client.Do(req) if err != nil { p.logger.Warn("Error sending visitor info", zap.Error(err)) @@ -205,6 +285,7 @@ func (p Umami) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp. } defer resp.Body.Close() + // Log error response from Umami API. if resp.StatusCode != http.StatusOK { respBody, err := io.ReadAll(resp.Body) if err != nil { @@ -220,10 +301,11 @@ func (p Umami) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp. } // Check whether analytics should be performed based on cookie consent settings. -// 0 - no analytics -// 1 - all analytics -// 2 - only path analytics +// - 0 - no analytics +// - 1 - all analytics +// - 2 - only path analytics func (p *Umami) GetAllowed(r *http.Request) int { + // Only the first value in the map is utilized currently. if len(p.CookieConsent) != 0 { if p.CookieConsent[0].Behavior == "path_only" { cookie, err := r.Cookie(p.CookieConsent[0].Name) @@ -393,6 +475,7 @@ func (p *Umami) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { return nil } +// Read static metadata from Caddyfile either as a single key value pair or as a block. func ParseCaddyfileStaticMetadata(d *caddyfile.Dispenser) ([]StaticMetadata, error) { var metadata []StaticMetadata @@ -437,6 +520,8 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) return umami, nil } +// Validate the Umami middleware configuration. +// Validate implements caddy.Validator. func (p *Umami) Validate() error { if p.EventEndpoint == "" { return fmt.Errorf("no event endpoint provided")