diff --git a/internal/component/home/home.go b/internal/component/home/home.go new file mode 100644 index 0000000..d607d57 --- /dev/null +++ b/internal/component/home/home.go @@ -0,0 +1,72 @@ +package home + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/FAU-CDI/wisski-distillery/internal/component" + "github.com/FAU-CDI/wisski-distillery/internal/component/instances" + "github.com/FAU-CDI/wisski-distillery/pkg/lazy" + "github.com/tkw1536/goprogram/stream" +) + +type Home struct { + component.ComponentBase + + Instances *instances.Instances + + RefreshInterval time.Duration + + redirect lazy.Lazy[*Redirect] + instanceNames lazy.Lazy[map[string]struct{}] + homeBytes lazy.Lazy[[]byte] +} + +func (*Home) Name() string { return "home" } + +func (*Home) Routes() []string { return []string{"/"} } + +func (home *Home) Handler(route string, context context.Context, io stream.IOStream) (http.Handler, error) { + home.updateRedirect(context, io) + home.updateInstances(context, io) + home.updateRender(context, io) + return home, nil +} + +func (home *Home) ServeHTTP(w http.ResponseWriter, r *http.Request) { + slug, ok := home.Config.SlugFromHost(r.Host) + switch { + case !ok: + http.NotFound(w, r) + case slug != "": + home.serveWissKI(w, slug, r) + default: + home.serveRoot(w, r) + } +} + +func (home *Home) serveRoot(w http.ResponseWriter, r *http.Request) { + // not the root url => server the fallback + if !(r.URL.Path == "" || r.URL.Path == "/") { + home.redirect.Get(nil).ServeHTTP(w, r) + return + } + + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusAccepted) + w.Write(home.homeBytes.Get(nil)) +} + +func (home *Home) serveWissKI(w http.ResponseWriter, slug string, r *http.Request) { + if _, ok := home.instanceNames.Get(nil)[slug]; !ok { + // Get(nil) guaranteed to work by precondition + w.WriteHeader(http.StatusNotFound) + fmt.Fprintf(w, "WissKI %q not found\n", slug) + return + } + + w.WriteHeader(http.StatusBadGateway) + fmt.Fprintf(w, "WissKI %q is currently offline\n", slug) +} diff --git a/internal/component/home/home.html b/internal/component/home/home.html new file mode 100644 index 0000000..3332e76 --- /dev/null +++ b/internal/component/home/home.html @@ -0,0 +1,25 @@ + + +WissKI Distillery +

WissKI Distillery

+ +

+ For more information, see {{ .SelfRedirect }}. +

+ +

WissKIs on this Distillery

+
+ {{range .Instances}} + {{ if .Running }} +

{{.Slug}}

+

+ {{.URL}}
+

+ {{ end }} + {{ end }} +
+ +
+ diff --git a/internal/component/home/public.go b/internal/component/home/public.go new file mode 100644 index 0000000..7bd3643 --- /dev/null +++ b/internal/component/home/public.go @@ -0,0 +1,90 @@ +package home + +import ( + "bytes" + "context" + "text/template" + "time" + + _ "embed" + + "github.com/FAU-CDI/wisski-distillery/internal/component/instances" + "github.com/FAU-CDI/wisski-distillery/pkg/timex" + "github.com/tkw1536/goprogram/stream" + "golang.org/x/sync/errgroup" +) + +func (home *Home) updateInstances(ctx context.Context, io stream.IOStream) { + timex.SetInterval(ctx, home.RefreshInterval, func(t time.Time) { + io.Printf("[%s]: reloading instance list", t.String()) + + names, _ := home.instanceMap() + home.instanceNames.Set(names) + }) +} + +func (home *Home) instanceMap() (map[string]struct{}, error) { + wissKIs, err := home.Instances.All() + if err != nil { + return nil, err + } + + names := make(map[string]struct{}, len(wissKIs)) + for _, w := range wissKIs { + names[w.Slug] = struct{}{} + } + return names, nil +} + +func (home *Home) updateRender(ctx context.Context, io stream.IOStream) { + timex.SetInterval(ctx, home.RefreshInterval, func(t time.Time) { + io.Printf("[%s]: reloading home render", t.String()) + + bytes, _ := home.homeRender() + home.homeBytes.Set(bytes) + }) +} + +//go:embed "home.html" +var homeHTMLStr string +var homeTemplate = template.Must(template.New("home.html").Parse(homeHTMLStr)) + +func (home *Home) homeRender() ([]byte, error) { + var context HomeContext + + // setup a couple of static things + context.Time = time.Now().UTC() + context.SelfRedirect = home.Config.SelfRedirect.String() + + // find all the WissKIs + wissKIs, err := home.Instances.All() + if err != nil { + return nil, err + } + context.Instances = make([]instances.WissKIInfo, len(wissKIs)) + + // determine their infos + var eg errgroup.Group + for i, instance := range wissKIs { + i := i + wissKI := instance + eg.Go(func() (err error) { + context.Instances[i], err = wissKI.Info(true) + return + }) + } + eg.Wait() + + // render the template + var buffer bytes.Buffer + homeTemplate.Execute(&buffer, context) + return buffer.Bytes(), nil +} + +type HomeContext struct { + Instances []instances.WissKIInfo + + Time time.Time + + SelfRedirect string +} diff --git a/internal/component/control/extras_self.go b/internal/component/home/redirect.go similarity index 61% rename from internal/component/control/extras_self.go rename to internal/component/home/redirect.go index c9bb4fb..9c652b2 100644 --- a/internal/component/control/extras_self.go +++ b/internal/component/home/redirect.go @@ -1,36 +1,35 @@ -package control +package home import ( "context" "encoding/json" - "fmt" "net/http" "strings" + "time" - "github.com/FAU-CDI/wisski-distillery/internal/component" - "github.com/FAU-CDI/wisski-distillery/internal/component/instances" + "github.com/FAU-CDI/wisski-distillery/pkg/timex" "github.com/tkw1536/goprogram/stream" ) -// SelfHandler implements serving the '/' route -type SelfHandler struct { - component.ComponentBase - - Instances *instances.Instances +func (home *Home) updateRedirect(ctx context.Context, io stream.IOStream) { + timex.SetInterval(ctx, home.RefreshInterval, func(t time.Time) { + io.Printf("[%s]: reloading overrides", t.String()) + redirect, _ := home.loadRedirect() + home.redirect.Set(&redirect) + }) } -func (SelfHandler) Name() string { return "control-self" } +func (home *Home) loadRedirect() (redirect Redirect, err error) { + if redirect.Overrides == nil { + redirect.Overrides = make(map[string]string) + } + redirect.Overrides[""] = home.Config.SelfRedirect.String() -func (*SelfHandler) Routes() []string { return []string{"/"} } + redirect.Absolute = false + redirect.Permanent = false -func (sh *SelfHandler) Handler(route string, context context.Context, io stream.IOStream) (http.Handler, error) { - // create a redirect - var redirect Redirect - var err error - - // open the overrides file - overrides, err := sh.Environment.Open(sh.Config.SelfOverridesFile) - io.Printf("loading overrides from %q\n", sh.Config.SelfOverridesFile) + // load the overrides file + overrides, err := home.Environment.Open(home.Config.SelfOverridesFile) if err != nil { return redirect, err } @@ -38,52 +37,13 @@ func (sh *SelfHandler) Handler(route string, context context.Context, io stream. // decode the overrides file if err := json.NewDecoder(overrides).Decode(&redirect.Overrides); err != nil { - return nil, err + return redirect, err } - if redirect.Overrides == nil { - redirect.Overrides = make(map[string]string) - } - redirect.Overrides[""] = sh.Config.SelfRedirect.String() - - // create a redirect server - redirect.Fallback, err = sh.selfFallback() - if err != nil { - return nil, err - } - redirect.Absolute = false - redirect.Permanent = false - // and return! return redirect, nil } -func (sh *SelfHandler) selfFallback() (http.Handler, error) { - return http.HandlerFunc(sh.serveFallback), nil -} - -var notFoundText = []byte("not found") - -func (sh *SelfHandler) serveFallback(w http.ResponseWriter, r *http.Request) { - - slug := sh.Config.SlugFromHost(r.Host) - if slug == "" { - w.WriteHeader(http.StatusNotFound) - w.Write(notFoundText) - return - } - - if ok, _ := sh.Instances.Has(slug); !ok { - w.WriteHeader(http.StatusNotFound) - fmt.Fprintf(w, "WissKI %q not found\n", slug) - return - } - - w.WriteHeader(http.StatusBadGateway) - fmt.Fprintf(w, "WissKI %q is currently offline\n", slug) - -} - // Redirect implements a redirect server that redirects all requests. // It implements http.Handler. type Redirect struct { diff --git a/internal/component/resolver/prefixes.go b/internal/component/resolver/prefixes.go new file mode 100644 index 0000000..689ea53 --- /dev/null +++ b/internal/component/resolver/prefixes.go @@ -0,0 +1,50 @@ +package resolver + +import ( + "context" + "time" + + "github.com/FAU-CDI/wisski-distillery/pkg/timex" + "github.com/tkw1536/goprogram/stream" +) + +// updatePrefixes starts updating prefixes +func (resolver *Resolver) updatePrefixes(io stream.IOStream, ctx context.Context) { + timex.SetInterval(ctx, resolver.RefreshInterval, func(t time.Time) { + io.Printf("[%s]: reloading prefixes", t.String()) + prefixes, _ := resolver.AllPrefixes() + resolver.prefixes.Set(prefixes) + }) +} + +// AllPrefixes returns a list of all prefixes from the server. +// Prefixes may be cached on the server +func (resolver *Resolver) AllPrefixes() (map[string]string, error) { + instances, err := resolver.Instances.All() + if err != nil { + return nil, err + } + + gPrefixes := make(map[string]string) + var lastErr error + for _, instance := range instances { + if instance.NoPrefix() { + continue + } + url := instance.URL().String() + + // failed to fetch prefixes for this particular instance + // => skip it! + prefixes, err := instance.PrefixesCached() + if err != nil { + lastErr = err + continue + } + + for _, p := range prefixes { + gPrefixes[p] = url + } + } + + return gPrefixes, lastErr +} diff --git a/internal/component/resolver/resolver.go b/internal/component/resolver/resolver.go index df4c56c..bedef61 100644 --- a/internal/component/resolver/resolver.go +++ b/internal/component/resolver/resolver.go @@ -52,7 +52,8 @@ func (resolver *Resolver) Handler(route string, context context.Context, io stre io.Printf("registering legacy domain %s\n", domain) } - go resolver.updatePrefixes(io, context) + // start updating prefixes + resolver.updatePrefixes(io, context) // resolve the prefixes p.Resolver = resolvers.InOrder{ @@ -63,62 +64,11 @@ func (resolver *Resolver) Handler(route string, context context.Context, io stre }), err } -func (resolver *Resolver) updatePrefixes(io stream.IOStream, ctx context.Context) { - t := time.NewTicker(resolver.RefreshInterval) - defer t.Stop() - - for { - select { - case <-t.C: - io.Println("resolver: Reloading prefixes from database") - prefixes, _ := resolver.AllPrefixes() - resolver.prefixes.Set(prefixes) - case <-ctx.Done(): - return - } - } -} - func (resolver *Resolver) Target(uri string) string { return wdresolve.PrefixTarget(resolver, uri) } // Prefixes returns a cached list of prefixes func (resolver *Resolver) Prefixes() (prefixes map[string]string) { - return resolver.prefixes.Get(func() map[string]string { - prefixes, _ := resolver.AllPrefixes() - return prefixes - }) -} - -// AllPrefixes returns a list of all prefixes from the server. -// Prefixes may be cached on the server -func (resolver *Resolver) AllPrefixes() (map[string]string, error) { - instances, err := resolver.Instances.All() - if err != nil { - return nil, err - } - - gPrefixes := make(map[string]string) - var lastErr error - for _, instance := range instances { - if instance.NoPrefix() { - continue - } - url := instance.URL().String() - - // failed to fetch prefixes for this particular instance - // => skip it! - prefixes, err := instance.PrefixesCached() - if err != nil { - lastErr = err - continue - } - - for _, p := range prefixes { - gPrefixes[p] = url - } - } - - return gPrefixes, lastErr + return resolver.prefixes.Get(nil) // by precondition there always is a cached value } diff --git a/internal/config/domains.go b/internal/config/domains.go index a465d25..f7c637a 100644 --- a/internal/config/domains.go +++ b/internal/config/domains.go @@ -42,18 +42,26 @@ func (cfg Config) DefaultSSLHost() string { return cfg.IfHttps(cfg.DefaultHost()) } -// SlugFromHost returns the slug belonging to the appropriate host. -func (cfg Config) SlugFromHost(host string) (slug string) { +// SlugFromHost returns the slug belonging to the appropriate host.' +// +// When host is a top-level domain, returns "", true. +// When no slug is found, returns "", false. +func (cfg Config) SlugFromHost(host string) (slug string, ok bool) { // extract an ':port' that happens to be in the host. domain, _, _ := strings.Cut(host, ":") + domainL := strings.ToLower(domain) // check all the possible domain endings for _, suffix := range append([]string{cfg.DefaultDomain}, cfg.SelfExtraDomains...) { - if strings.HasSuffix(domain, "."+suffix) { - return domain[:len(domain)-len(suffix)-1] + suffixL := strings.ToLower(suffix) + if domainL == suffixL { + return "", true + } + if strings.HasSuffix(domainL, "."+suffixL) { + return domain[:len(domain)-len(suffix)-1], true } } // no domain found! - return "" + return "", ok } diff --git a/internal/dis/component.go b/internal/dis/component.go index 045711f..094ce71 100644 --- a/internal/dis/component.go +++ b/internal/dis/component.go @@ -5,6 +5,7 @@ import ( "github.com/FAU-CDI/wisski-distillery/internal/component" "github.com/FAU-CDI/wisski-distillery/internal/component/control" + "github.com/FAU-CDI/wisski-distillery/internal/component/home" "github.com/FAU-CDI/wisski-distillery/internal/component/instances" "github.com/FAU-CDI/wisski-distillery/internal/component/resolver" "github.com/FAU-CDI/wisski-distillery/internal/component/snapshots" @@ -43,7 +44,9 @@ func (dis *Distillery) register(context *component.PoolContext) []component.Comp // Control server ra[*control.Control](dis, context), - ra[*control.SelfHandler](dis, context), + r(dis, context, func(home *home.Home) { + home.RefreshInterval = time.Minute + }), r(dis, context, func(resolver *resolver.Resolver) { resolver.RefreshInterval = time.Minute }), diff --git a/pkg/timex/timex.go b/pkg/timex/timex.go new file mode 100644 index 0000000..c8562c8 --- /dev/null +++ b/pkg/timex/timex.go @@ -0,0 +1,25 @@ +package timex + +import ( + "context" + "time" +) + +// SetInterval invokes f with the current time and then spawns a new goroutine that runs f every d, until context is closed. +func SetInterval(ctx context.Context, d time.Duration, f func(t time.Time)) { + f(time.Now()) + + go func() { + t := time.NewTicker(d) + defer t.Stop() + + for { + select { + case tick := <-t.C: + f(tick) + case <-ctx.Done(): + return + } + } + }() +}