feat(session): support session tracking
This change refactors a handful of components to make them reusable across pageview and session tracking endpoints. Resolves https://github.com/mjpitz/pages/issues/2
This commit is contained in:
parent
9a745569c4
commit
02d115f682
27
README.md
27
README.md
@ -47,19 +47,30 @@ system.
|
|||||||
The number of page views for a given path and their associated referrer.
|
The number of page views for a given path and their associated referrer.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
# HELP pages_page_view_count page views
|
# HELP pages_page_view_count the number of times a given page has been viewed and by what referrer
|
||||||
# TYPE pages_page_view_count counter
|
# TYPE pages_page_view_count counter
|
||||||
pages_page_view_count{country="",path="/charts/",referrer="http://localhost:8080/blog/"} 1
|
pages_page_view_count{country="",path="/charts/",referrer="http://localhost:8080/blog/"} 1
|
||||||
```
|
```
|
||||||
|
|
||||||
### pages_page_session_duration
|
### pages_page_session_seconds
|
||||||
|
|
||||||
How long a user is actively engaged with the page.
|
Histogram of how long users spend on a page.
|
||||||
|
|
||||||
https://github.com/mjpitz/pages/issues/2
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
# HELP pages_page_session_duration time spent on a given page in seconds
|
# HELP pages_page_session_seconds how long someone spent on a given page
|
||||||
# TYPE pages_page_session_duration histogram
|
# TYPE pages_page_session_seconds histogram
|
||||||
pages_page_session_duration{country="",path="/charts/"}
|
pages_page_session_seconds_bucket{country="",path="/",le="0.005"} 0
|
||||||
|
pages_page_session_seconds_bucket{country="",path="/",le="0.01"} 0
|
||||||
|
pages_page_session_seconds_bucket{country="",path="/",le="0.025"} 0
|
||||||
|
pages_page_session_seconds_bucket{country="",path="/",le="0.05"} 0
|
||||||
|
pages_page_session_seconds_bucket{country="",path="/",le="0.1"} 0
|
||||||
|
pages_page_session_seconds_bucket{country="",path="/",le="0.25"} 0
|
||||||
|
pages_page_session_seconds_bucket{country="",path="/",le="0.5"} 0
|
||||||
|
pages_page_session_seconds_bucket{country="",path="/",le="1"} 0
|
||||||
|
pages_page_session_seconds_bucket{country="",path="/",le="2.5"} 1
|
||||||
|
pages_page_session_seconds_bucket{country="",path="/",le="5"} 1
|
||||||
|
pages_page_session_seconds_bucket{country="",path="/",le="10"} 1
|
||||||
|
pages_page_session_seconds_bucket{country="",path="/",le="+Inf"} 1
|
||||||
|
pages_page_session_seconds_sum{country="",path="/"} 1.855976549
|
||||||
|
pages_page_session_seconds_count{country="",path="/"} 1
|
||||||
```
|
```
|
||||||
|
1
go.mod
1
go.mod
@ -11,6 +11,7 @@ require (
|
|||||||
github.com/go-git/go-billy/v5 v5.3.1
|
github.com/go-git/go-billy/v5 v5.3.1
|
||||||
github.com/go-git/go-git/v5 v5.4.2
|
github.com/go-git/go-git/v5 v5.4.2
|
||||||
github.com/gorilla/mux v1.8.0
|
github.com/gorilla/mux v1.8.0
|
||||||
|
github.com/gorilla/websocket v1.5.0
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/prometheus/client_golang v1.12.2
|
github.com/prometheus/client_golang v1.12.2
|
||||||
go.uber.org/zap v1.19.1
|
go.uber.org/zap v1.19.1
|
||||||
|
2
go.sum
2
go.sum
@ -171,6 +171,8 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
|
|||||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||||
|
62
internal/excludes/excludes.go
Normal file
62
internal/excludes/excludes.go
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
// Copyright (C) 2022 The pages authors
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
|
||||||
|
package excludes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exclusion defines an abstraction for matching paths.
|
||||||
|
type Exclusion func(s string) bool
|
||||||
|
|
||||||
|
// AnyExclusion returns a matcher who returns true if any of the provided matchers match the string.
|
||||||
|
func AnyExclusion(exclusions ...Exclusion) Exclusion {
|
||||||
|
return func(s string) bool {
|
||||||
|
for _, exclusion := range exclusions {
|
||||||
|
if exclusion(s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetExclusion returns a matcher who returns true when an asset file is requested.
|
||||||
|
func AssetExclusion() Exclusion {
|
||||||
|
return func(s string) bool {
|
||||||
|
return path.Ext(s) != ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrefixExclusion returns a matcher who returns true if the string matches the provided prefix.
|
||||||
|
func PrefixExclusion(prefix string) Exclusion {
|
||||||
|
return func(s string) bool {
|
||||||
|
return strings.HasPrefix(s, prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegexExclusion returns a matcher who returns true if the string matches the provided regular expression pattern.
|
||||||
|
func RegexExclusion(pattern string) Exclusion {
|
||||||
|
exp := regexp.MustCompile(pattern)
|
||||||
|
|
||||||
|
return func(s string) bool {
|
||||||
|
return exp.MatchString(s)
|
||||||
|
}
|
||||||
|
}
|
70
internal/geoip/middleware.go
Normal file
70
internal/geoip/middleware.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// Copyright (C) 2022 The pages authors
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
|
||||||
|
package geoip
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"github.com/mjpitz/myago"
|
||||||
|
)
|
||||||
|
|
||||||
|
var key = myago.ContextKey("geoip.info")
|
||||||
|
|
||||||
|
type Info struct {
|
||||||
|
CountryCode string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Extract(ctx context.Context) Info {
|
||||||
|
val := ctx.Value(key)
|
||||||
|
v, ok := val.(Info)
|
||||||
|
|
||||||
|
if val == nil || !ok {
|
||||||
|
return Info{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
type Interface interface {
|
||||||
|
Lookup(ip string) Info
|
||||||
|
}
|
||||||
|
|
||||||
|
type Empty struct{}
|
||||||
|
|
||||||
|
func (e Empty) Lookup(ip string) Info {
|
||||||
|
return Info{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Middleware(geoip Interface) mux.MiddlewareFunc {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), key, geoip.Lookup(clientIP))
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -31,8 +31,18 @@ var (
|
|||||||
Namespace: namespace,
|
Namespace: namespace,
|
||||||
Subsystem: page,
|
Subsystem: page,
|
||||||
Name: "view_count",
|
Name: "view_count",
|
||||||
Help: "page views",
|
Help: "the number of times a given page has been viewed and by what referrer",
|
||||||
},
|
},
|
||||||
[]string{"path", "referrer", "country"},
|
[]string{"path", "referrer", "country"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
PageSessionDuration = promauto.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Namespace: namespace,
|
||||||
|
Subsystem: page,
|
||||||
|
Name: "session_seconds",
|
||||||
|
Help: "how long someone spent on a given page",
|
||||||
|
},
|
||||||
|
[]string{"path", "country"},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
@ -14,73 +14,40 @@
|
|||||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
//
|
//
|
||||||
|
|
||||||
package internal
|
package pageviews
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"github.com/mjpitz/pages/internal/excludes"
|
||||||
|
"github.com/mjpitz/pages/internal/geoip"
|
||||||
"github.com/mjpitz/pages/internal/metrics"
|
"github.com/mjpitz/pages/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GeoIP provides an abstraction for looking up a country code from an IP address.
|
type opt struct {
|
||||||
type GeoIP interface {
|
excludes []excludes.Exclusion
|
||||||
Lookup(ip string) (countryCode string)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type emptyGeoIP struct{}
|
// Option provides a way to configure elements of the Middleware.
|
||||||
|
type Option func(*opt)
|
||||||
|
|
||||||
func (e emptyGeoIP) Lookup(ip string) (countryCode string) {
|
// Exclusions appends the provided rules to the excludes list. Any path that matches an exclusion will not be measured.
|
||||||
return ""
|
func Exclusions(exclusions ...excludes.Exclusion) Option {
|
||||||
}
|
return func(o *opt) {
|
||||||
|
o.excludes = append(o.excludes, exclusions...)
|
||||||
// Matcher defines an abstraction for matching paths.
|
|
||||||
type Matcher func(s string) bool
|
|
||||||
|
|
||||||
// AnyMatcher returns a matcher who returns true if any of the provided matchers match the string.
|
|
||||||
func AnyMatcher(matchers ...Matcher) Matcher {
|
|
||||||
return func(s string) bool {
|
|
||||||
for _, matcher := range matchers {
|
|
||||||
if matcher(s) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssetMatcher returns a matcher who returns true when an asset file is requested.
|
|
||||||
func AssetMatcher() Matcher {
|
|
||||||
return func(s string) bool {
|
|
||||||
return path.Ext(s) != ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PrefixMatcher returns a matcher who returns true if the string matches the provided prefix.
|
|
||||||
func PrefixMatcher(prefix string) Matcher {
|
|
||||||
return func(s string) bool {
|
|
||||||
return strings.HasPrefix(s, prefix)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegexMatcher returns a matcher who returns true if the string matches the provided regular expression pattern.
|
|
||||||
func RegexMatcher(pattern string) Matcher {
|
|
||||||
exp := regexp.MustCompile(pattern)
|
|
||||||
|
|
||||||
return func(s string) bool {
|
|
||||||
return exp.MatchString(s)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middleware produces an HTTP middleware function that reports page views.
|
// Middleware produces an HTTP middleware function that reports page views.
|
||||||
func Middleware(geoIP GeoIP, excludes ...Matcher) mux.MiddlewareFunc {
|
func Middleware(opts ...Option) mux.MiddlewareFunc {
|
||||||
exclude := AnyMatcher(excludes...)
|
o := opt{}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&o)
|
||||||
|
}
|
||||||
|
|
||||||
|
exclude := excludes.AnyExclusion(o.excludes...)
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -89,18 +56,12 @@ func Middleware(geoIP GeoIP, excludes ...Matcher) mux.MiddlewareFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
|
info := geoip.Extract(r.Context())
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
countryCode := geoIP.Lookup(clientIP)
|
|
||||||
|
|
||||||
d := &writer{w, http.StatusOK}
|
d := &writer{w, http.StatusOK}
|
||||||
defer func() {
|
defer func() {
|
||||||
if d.statusCode != http.StatusNotFound {
|
if d.statusCode != http.StatusNotFound {
|
||||||
metrics.PageViewCount.WithLabelValues(r.URL.Path, r.Referer(), countryCode).Inc()
|
metrics.PageViewCount.WithLabelValues(r.URL.Path, r.Referer(), info.CountryCode).Inc()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
@ -20,6 +20,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
@ -31,11 +32,16 @@ import (
|
|||||||
httpauth "github.com/mjpitz/myago/auth/http"
|
httpauth "github.com/mjpitz/myago/auth/http"
|
||||||
"github.com/mjpitz/myago/headers"
|
"github.com/mjpitz/myago/headers"
|
||||||
"github.com/mjpitz/myago/livetls"
|
"github.com/mjpitz/myago/livetls"
|
||||||
|
"github.com/mjpitz/pages/internal/excludes"
|
||||||
|
"github.com/mjpitz/pages/internal/geoip"
|
||||||
|
"github.com/mjpitz/pages/internal/pageviews"
|
||||||
|
"github.com/mjpitz/pages/internal/session"
|
||||||
|
"github.com/mjpitz/pages/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminConfig encapsulates configuration for the administrative process.
|
// AdminConfig encapsulates configuration for the administrative endpoints.
|
||||||
type AdminConfig struct {
|
type AdminConfig struct {
|
||||||
Prefix string `json:"prefix" usage:"configure the prefix to use for admin endpoints" default:"/_admin"`
|
Prefix string `json:"prefix" usage:"configure the prefix to use for admin endpoints" default:"/_admin" hidden:"true"`
|
||||||
Username string `json:"username" usage:"specify the username used to authenticate requests with the admin endpoints" default:"admin"`
|
Username string `json:"username" usage:"specify the username used to authenticate requests with the admin endpoints" default:"admin"`
|
||||||
Password string `json:"password" usage:"specify the password used to authenticate requests with the admin endpoints"`
|
Password string `json:"password" usage:"specify the password used to authenticate requests with the admin endpoints"`
|
||||||
}
|
}
|
||||||
@ -48,6 +54,7 @@ type BindConfig struct {
|
|||||||
// ServerConfig defines configuration for a public and private interface.
|
// ServerConfig defines configuration for a public and private interface.
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Admin AdminConfig `json:"admin"`
|
Admin AdminConfig `json:"admin"`
|
||||||
|
Session session.Config `json:"session"`
|
||||||
TLS livetls.Config `json:"tls"`
|
TLS livetls.Config `json:"tls"`
|
||||||
Public BindConfig `json:"public"`
|
Public BindConfig `json:"public"`
|
||||||
Private BindConfig `json:"private"`
|
Private BindConfig `json:"private"`
|
||||||
@ -63,17 +70,26 @@ func NewServer(ctx context.Context, config ServerConfig) (*Server, error) {
|
|||||||
private := mux.NewRouter()
|
private := mux.NewRouter()
|
||||||
private.Handle("/metrics", promhttp.Handler())
|
private.Handle("/metrics", promhttp.Handler())
|
||||||
|
|
||||||
|
exclusions := []excludes.Exclusion{
|
||||||
|
excludes.AssetExclusion(),
|
||||||
|
excludes.PrefixExclusion(config.Admin.Prefix),
|
||||||
|
excludes.PrefixExclusion(config.Session.Prefix),
|
||||||
|
}
|
||||||
|
|
||||||
public := mux.NewRouter()
|
public := mux.NewRouter()
|
||||||
public.Use(
|
public.Use(
|
||||||
func(next http.Handler) http.Handler { return headers.HTTP(next) },
|
func(next http.Handler) http.Handler { return headers.HTTP(next) },
|
||||||
Middleware(
|
geoip.Middleware(geoip.Empty{}),
|
||||||
emptyGeoIP{},
|
pageviews.Middleware(
|
||||||
AssetMatcher(),
|
pageviews.Exclusions(exclusions...),
|
||||||
PrefixMatcher(config.Admin.Prefix),
|
),
|
||||||
|
session.Middleware(
|
||||||
|
session.Exclusions(exclusions...),
|
||||||
|
session.JavaScriptPath(path.Join(config.Session.Prefix, "pages.js")),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
admin := public.Path(config.Admin.Prefix).Subrouter()
|
admin := public.PathPrefix(config.Admin.Prefix).Subrouter()
|
||||||
|
|
||||||
if config.Admin.Password != "" {
|
if config.Admin.Password != "" {
|
||||||
authenticate := basicauth.Static(config.Admin.Username, config.Admin.Password)
|
authenticate := basicauth.Static(config.Admin.Username, config.Admin.Password)
|
||||||
@ -84,6 +100,15 @@ func NewServer(ctx context.Context, config ServerConfig) (*Server, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
var handler http.Handler = session.Handler()
|
||||||
|
handler = http.StripPrefix(config.Session.Prefix, handler)
|
||||||
|
|
||||||
|
session := public.PathPrefix(config.Session.Prefix).Subrouter()
|
||||||
|
session.HandleFunc("/pages.js", web.Handler()).Methods(http.MethodGet)
|
||||||
|
session.Handle("/", handler)
|
||||||
|
}
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
AdminMux: admin,
|
AdminMux: admin,
|
||||||
|
|
||||||
|
22
internal/session/config.go
Normal file
22
internal/session/config.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// Copyright (C) 2022 The pages authors
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
|
||||||
|
package session
|
||||||
|
|
||||||
|
// Config encapsulates configuration for the session endpoints.
|
||||||
|
type Config struct {
|
||||||
|
Prefix string `json:"prefix" usage:"configure the prefix to use for recording sessions" default:"/_session" hidden:"true"`
|
||||||
|
}
|
73
internal/session/handler.go
Normal file
73
internal/session/handler.go
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
// Copyright (C) 2022 The pages authors
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"github.com/mjpitz/pages/internal/geoip"
|
||||||
|
"github.com/mjpitz/pages/internal/metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
ID string
|
||||||
|
FullName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Handler() *Handle {
|
||||||
|
return &Handle{
|
||||||
|
upgrader: websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handle struct {
|
||||||
|
upgrader websocket.Upgrader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handle) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := h.upgrader.Upgrade(w, r, nil)
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// log
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := r.URL.Path
|
||||||
|
geoInfo := geoip.Extract(r.Context())
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
defer func() {
|
||||||
|
metrics.PageSessionDuration.WithLabelValues(path, geoInfo.CountryCode).Observe(time.Since(start).Seconds())
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
req := Request{}
|
||||||
|
|
||||||
|
err = conn.ReadJSON(&req)
|
||||||
|
if err != nil {
|
||||||
|
// log
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
124
internal/session/middleware.go
Normal file
124
internal/session/middleware.go
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
// Copyright (C) 2022 The pages authors
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"github.com/mjpitz/pages/internal/excludes"
|
||||||
|
)
|
||||||
|
|
||||||
|
func script(path string) string {
|
||||||
|
return fmt.Sprintf(`<script async type='text/javascript' src='%s'></script>`, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
type opt struct {
|
||||||
|
jsPath string
|
||||||
|
excludes []excludes.Exclusion
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option provides a way to configure elements of the Middleware.
|
||||||
|
type Option func(*opt)
|
||||||
|
|
||||||
|
// JavaScriptPath configures the URL for the JS snippet.
|
||||||
|
func JavaScriptPath(path string) Option {
|
||||||
|
return func(o *opt) {
|
||||||
|
o.jsPath = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclusions appends the provided rules to the excludes list. Any path that matches an exclusion will not be measured.
|
||||||
|
func Exclusions(exclusions ...excludes.Exclusion) Option {
|
||||||
|
return func(o *opt) {
|
||||||
|
o.excludes = append(o.excludes, exclusions...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware injects a JavaScript snippet that establishes a session with the server.
|
||||||
|
func Middleware(opts ...Option) mux.MiddlewareFunc {
|
||||||
|
o := opt{}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&o)
|
||||||
|
}
|
||||||
|
|
||||||
|
exclude := excludes.AnyExclusion(o.excludes...)
|
||||||
|
injection := script(o.jsPath)
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if exclude(r.URL.Path) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer := &bufferedResponseWriter{
|
||||||
|
header: http.Header{},
|
||||||
|
status: http.StatusOK,
|
||||||
|
buffer: bytes.NewBuffer(nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(buffer, r)
|
||||||
|
|
||||||
|
for key := range buffer.header {
|
||||||
|
w.Header().Set(key, buffer.header.Get(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
contents := buffer.buffer.Bytes()
|
||||||
|
|
||||||
|
if strings.Contains(buffer.header.Get("Content-Type"), "text/html") {
|
||||||
|
contents = bytes.TrimSpace(contents)
|
||||||
|
contents = bytes.TrimSuffix(contents, []byte("</html>"))
|
||||||
|
contents = bytes.TrimSpace(contents)
|
||||||
|
contents = bytes.TrimSuffix(contents, []byte("</body>"))
|
||||||
|
|
||||||
|
contents = append(contents, []byte(injection)...)
|
||||||
|
contents = append(contents, []byte("</body></html>")...)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(contents)))
|
||||||
|
w.WriteHeader(buffer.status)
|
||||||
|
|
||||||
|
_, _ = w.Write(contents)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type bufferedResponseWriter struct {
|
||||||
|
header http.Header
|
||||||
|
status int
|
||||||
|
buffer *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *bufferedResponseWriter) Header() http.Header {
|
||||||
|
return w.header
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *bufferedResponseWriter) WriteHeader(statusCode int) {
|
||||||
|
w.status = statusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *bufferedResponseWriter) Write(data []byte) (n int, err error) {
|
||||||
|
return w.buffer.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ http.ResponseWriter = &bufferedResponseWriter{}
|
2
internal/web/.gitignore
vendored
Normal file
2
internal/web/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
1041
internal/web/package-lock.json
generated
Normal file
1041
internal/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
internal/web/package.json
Normal file
15
internal/web/package.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@mjpitz/pages-web",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": "true",
|
||||||
|
"scripts": {
|
||||||
|
"build": "webpack build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"webpack": "^5.73.0",
|
||||||
|
"webpack-cli": "^4.9.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ulid": "^2.3.0"
|
||||||
|
}
|
||||||
|
}
|
33
internal/web/src/index.js
Normal file
33
internal/web/src/index.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (C) 2022 The pages authors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {ulid} from 'ulid';
|
||||||
|
|
||||||
|
const sessionPrefix = "/_session";
|
||||||
|
const domain = window.location.host;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const ws = new WebSocket(`ws://${domain}${sessionPrefix}${window.location.pathname}`);
|
||||||
|
const message = `{"ID":"${ulid()}","FullName":"ping"}`
|
||||||
|
|
||||||
|
let id = null;
|
||||||
|
ws.onopen = () => id = setInterval(() => ws.send(message), 5000);
|
||||||
|
ws.onclose = () => clearInterval(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => console.error(err))
|
38
internal/web/web.go
Normal file
38
internal/web/web.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
// Copyright (C) 2022 The pages authors
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:generate npm install
|
||||||
|
//go:generate npm run build
|
||||||
|
|
||||||
|
//go:embed dist/pages.js
|
||||||
|
var pagesjs string
|
||||||
|
|
||||||
|
func Handler() http.HandlerFunc {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.ServeContent(w, r, "pages.js", start, strings.NewReader(pagesjs))
|
||||||
|
}
|
||||||
|
}
|
28
internal/web/webpack.config.js
Normal file
28
internal/web/webpack.config.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (C) 2022 The pages authors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entry: './src/index.js',
|
||||||
|
output: {
|
||||||
|
path: path.resolve(__dirname, 'dist'),
|
||||||
|
filename: 'pages.js',
|
||||||
|
},
|
||||||
|
mode: process.env.PAGES_ENV || 'production',
|
||||||
|
}
|
@ -20,6 +20,8 @@ set -e -o pipefail
|
|||||||
go mod download
|
go mod download
|
||||||
go mod verify
|
go mod verify
|
||||||
|
|
||||||
|
go generate ./...
|
||||||
|
|
||||||
if [[ -z "${VERSION}" ]]; then
|
if [[ -z "${VERSION}" ]]; then
|
||||||
goreleaser --snapshot --skip-publish --rm-dist
|
goreleaser --snapshot --skip-publish --rm-dist
|
||||||
else
|
else
|
||||||
|
Loading…
Reference in New Issue
Block a user