From 9db53d39c44e08ab377a6964d575806f2368d700 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 4 May 2023 15:13:51 +0200 Subject: [PATCH] Implement basic API scoping --- internal/config/http.go | 22 --- internal/dis/component/auth/api/handler.go | 175 ++++++++++++++++++ internal/dis/component/auth/next/next.go | 3 +- internal/dis/component/auth/panel/panel.go | 5 +- internal/dis/component/auth/scopes/admin.go | 6 +- .../dis/component/auth/scopes/instances.go | 38 ++++ internal/dis/component/auth/scopes/news.go | 37 ++++ internal/dis/component/auth/scopes/scopes.go | 6 + internal/dis/component/auth/scopes/user.go | 6 +- internal/dis/component/resolver/resolver.go | 3 +- internal/dis/component/scope.go | 5 - internal/dis/component/server/admin/admin.go | 5 +- internal/dis/component/server/home/api.go | 64 ------- internal/dis/component/server/home/home.go | 28 +-- .../dis/component/server/home/instances.go | 86 --------- internal/dis/component/server/home/public.go | 21 +-- .../dis/component/server/home/public.html | 30 ++- internal/dis/component/server/list/api.go | 71 +++++++ internal/dis/component/server/list/list.go | 130 +++++++++++++ internal/dis/component/server/news/api.go | 34 ++-- internal/dis/distillery.go | 8 +- 21 files changed, 519 insertions(+), 264 deletions(-) create mode 100644 internal/dis/component/auth/api/handler.go create mode 100644 internal/dis/component/auth/scopes/instances.go create mode 100644 internal/dis/component/auth/scopes/news.go create mode 100644 internal/dis/component/auth/scopes/scopes.go delete mode 100644 internal/dis/component/server/home/api.go delete mode 100644 internal/dis/component/server/home/instances.go create mode 100644 internal/dis/component/server/list/api.go create mode 100644 internal/dis/component/server/list/list.go diff --git a/internal/config/http.go b/internal/config/http.go index 5bda9f2..2a56771 100644 --- a/internal/config/http.go +++ b/internal/config/http.go @@ -2,12 +2,10 @@ package config import ( "fmt" - "net/http" "net/url" "strings" "github.com/FAU-CDI/wisski-distillery/internal/config/validators" - "github.com/tkw1536/pkglib/httpx" "golang.org/x/net/idna" ) @@ -31,26 +29,6 @@ type HTTPConfig struct { API validators.NullableBool `yaml:"api" validate:"bool" default:"false"` } -var apiNotEnabled = httpx.Response{ - StatusCode: http.StatusForbidden, - Body: []byte(`{"message":"API is not enabled"}`), -} - -func (hcfg HTTPConfig) APIDecorator(methods ...string) func(http.Handler) http.Handler { - methods = append(methods, "OPTIONS") // always permit the options method! - - if !hcfg.API.Value { - return func(http.Handler) http.Handler { - return httpx.PermitMethods(apiNotEnabled, methods...) - } - } - - // permit only the specified methods - return func(h http.Handler) http.Handler { - return httpx.PermitMethods(h, methods...) - } -} - // JoinPath returns the root public url joined with the provided parts. func (hcfg HTTPConfig) JoinPath(elem ...string) *url.URL { u := url.URL{ diff --git a/internal/dis/component/auth/api/handler.go b/internal/dis/component/auth/api/handler.go new file mode 100644 index 0000000..8b321c9 --- /dev/null +++ b/internal/dis/component/auth/api/handler.go @@ -0,0 +1,175 @@ +// Package api implements a common handler used by the api routes +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/FAU-CDI/wisski-distillery/internal/config" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes" + "github.com/rs/zerolog" + "github.com/tkw1536/pkglib/httpx" + "github.com/tkw1536/pkglib/lazy" +) + +// Handler represents an API handler that returns a REST response. +// The response is automatically marshaled using T. +type Handler[T any] struct { + Config *config.Config + Auth *auth.Auth // Handler to handle Auth + + Methods []string // HTTP methods to allow + methods lazy.Lazy[map[string]struct{}] + + Scope scopes.Scope + ScopeParam func(*http.Request) string + Handler func(string, *http.Request) (T, error) +} + +var apiNotEnabled = &Response{ + Status: http.StatusNotImplemented, + Message: "API is not implemented on this server", +} + +var apiMethodNotAllowed = &Response{ + Status: http.StatusMethodNotAllowed, + Message: "method not allowed", +} + +var apiInternalServerError = &Response{ + Status: http.StatusInternalServerError, + Message: "internal server error", +} + +var apiBadRequest = &Response{ + Status: http.StatusBadRequest, + Message: "bad request", +} + +var apiNotFound = &Response{ + Status: http.StatusNotFound, + Message: "not found", +} + +var apiForbidden = &Response{ + Status: http.StatusForbidden, + Message: "forbidden", +} + +// ServeHTTP servers an api call +func (handler *Handler[T]) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // check that the api is actually enabled! + if !handler.Config.HTTP.API.Value { + apiNotEnabled.ServeHTTP(w, r) + return + } + + // get the permitted methods + methods := handler.methods.Get(func() map[string]struct{} { + m := make(map[string]struct{}, len(handler.Methods)+1) + for _, method := range handler.Methods { + m[method] = struct{}{} + } + m["OPTIONS"] = struct{}{} + return m + }) + + // check that the method is permitted + if _, ok := methods[r.Method]; !ok { + apiMethodNotAllowed.ServeHTTP(w, r) + return + } + + // we now delegate to user-level code; + // so we now need to make sure that panic()s are caught. + var stage string + defer func() { + // recover any error + rec := recover() + if rec == nil { + return + } + + // log the error, and serve the default internal server error + zerolog.Ctx(r.Context()).Error().Str("panic", fmt.Sprint(rec)).Str("stage", stage).Str("route", r.URL.RequestURI()).Msg("api handler caused panic()") + apiInternalServerError.ServeHTTP(w, r) + }() + + // read the parameter + stage = "param" + var param string + if handler.ScopeParam != nil { + param = handler.ScopeParam(r) + } + + // check that the scope is correct + stage = "scope" + if err := handler.Auth.CheckScope(param, handler.Scope, r); err != nil { + (&Response{ + Status: http.StatusForbidden, + Message: err.Error(), + }).ServeHTTP(w, r) + return + } + + stage = "handler" + + result, err := handler.Handler(param, r) + switch true { + case err == nil: /* keep going */ + + // handle common httpx errors + case errors.Is(err, httpx.ErrInternalServerError): + apiInternalServerError.ServeHTTP(w, r) + return + case errors.Is(err, httpx.ErrBadRequest): + apiBadRequest.ServeHTTP(w, r) + return + case errors.Is(err, httpx.ErrNotFound): + apiNotFound.ServeHTTP(w, r) + return + case errors.Is(err, httpx.ErrForbidden): + apiForbidden.ServeHTTP(w, r) + return + case errors.Is(err, httpx.ErrMethodNotAllowed): + apiMethodNotAllowed.ServeHTTP(w, r) + return + + // generic error + default: + (&Response{ + Status: http.StatusInternalServerError, + Message: err.Error(), + }).ServeHTTP(w, r) + return + } + + stage = "marshal" + + // encode the result into json and send it as the response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(result) +} + +// Response represents a generic response to any request. +// Response objects cache response serialization +type Response struct { + Status int `json:"status"` + Message string `json:"message"` + res lazy.Lazy[httpx.Response] +} + +func (g *Response) ServeHTTP(w http.ResponseWriter, r *http.Request) { + g.res.Get(func() httpx.Response { + bytes, _ := json.Marshal(g) + return httpx.Response{ + ContentType: "application/json", + Body: bytes, + StatusCode: g.Status, + } + }).ServeHTTP(w, r) +} diff --git a/internal/dis/component/auth/next/next.go b/internal/dis/component/auth/next/next.go index 00483e4..9c85c56 100644 --- a/internal/dis/component/auth/next/next.go +++ b/internal/dis/component/auth/next/next.go @@ -8,6 +8,7 @@ import ( "github.com/FAU-CDI/wisski-distillery/internal/dis/component" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/policy" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances" "github.com/FAU-CDI/wisski-distillery/internal/wisski" "github.com/FAU-CDI/wisski-distillery/internal/wisski/ingredient/php/users" @@ -30,7 +31,7 @@ var ( func (next *Next) Routes() component.Routes { return component.Routes{ Prefix: "/next/", - Decorator: next.Dependencies.Auth.Require(component.ScopeUserLoggedIn, nil), + Decorator: next.Dependencies.Auth.Require(scopes.ScopeUserLoggedIn, nil), } } diff --git a/internal/dis/component/auth/panel/panel.go b/internal/dis/component/auth/panel/panel.go index 78906ac..f686c91 100644 --- a/internal/dis/component/auth/panel/panel.go +++ b/internal/dis/component/auth/panel/panel.go @@ -8,6 +8,7 @@ import ( "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/next" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/policy" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templating" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/ssh2" @@ -39,7 +40,7 @@ func (panel *UserPanel) Routes() component.Routes { return component.Routes{ Prefix: "/user/", CSRF: true, - Decorator: panel.Dependencies.Auth.Require(component.ScopeUserLoggedIn, nil), + Decorator: panel.Dependencies.Auth.Require(scopes.ScopeUserLoggedIn, nil), } } @@ -115,7 +116,7 @@ func (panel *UserPanel) HandleRoute(ctx context.Context, route string) (http.Han } // ensure that the user is logged in! - return panel.Dependencies.Auth.Protect(router, component.ScopeUserLoggedIn, nil), nil + return panel.Dependencies.Auth.Protect(router, scopes.ScopeUserLoggedIn, nil), nil } type userFormContext struct { diff --git a/internal/dis/component/auth/scopes/admin.go b/internal/dis/component/auth/scopes/admin.go index 3918bc2..2bf1da1 100644 --- a/internal/dis/component/auth/scopes/admin.go +++ b/internal/dis/component/auth/scopes/admin.go @@ -18,9 +18,13 @@ var ( _ component.ScopeProvider = (*UserLoggedIn)(nil) ) +const ( + ScopeAdminLoggedIn Scope = "login.admin" +) + func (*AdminLoggedIn) Scope() component.ScopeInfo { return component.ScopeInfo{ - Scope: component.ScopeAdminLoggedIn, + Scope: ScopeAdminLoggedIn, Description: "session has a signed in admin", DeniedMessage: "user must be signed into an admin account with TOTP enabled", TakesParam: false, diff --git a/internal/dis/component/auth/scopes/instances.go b/internal/dis/component/auth/scopes/instances.go new file mode 100644 index 0000000..1bb7002 --- /dev/null +++ b/internal/dis/component/auth/scopes/instances.go @@ -0,0 +1,38 @@ +package scopes + +import ( + "net/http" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" +) + +type ListInstancesScope struct { + component.Base + Dependencies struct { + Auth *auth.Auth + } +} + +var ( + _ component.ScopeProvider = (*ListInstancesScope)(nil) +) + +const ( + ScopeInstanceDirectory Scope = "instances.directory" +) + +func (*ListInstancesScope) Scope() component.ScopeInfo { + return component.ScopeInfo{ + Scope: ScopeInstanceDirectory, + Description: "get a public directory of instances", + DeniedMessage: "", + TakesParam: false, + } +} + +func (lis *ListInstancesScope) HasScope(param string, r *http.Request) (bool, error) { + // TODO: at the moment everyone has this permission + // this should change in the future! + return true, nil +} diff --git a/internal/dis/component/auth/scopes/news.go b/internal/dis/component/auth/scopes/news.go new file mode 100644 index 0000000..3e38ddd --- /dev/null +++ b/internal/dis/component/auth/scopes/news.go @@ -0,0 +1,37 @@ +package scopes + +import ( + "net/http" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" +) + +type ListNewsScope struct { + component.Base + Dependencies struct { + Auth *auth.Auth + } +} + +var ( + _ component.ScopeProvider = (*ListNewsScope)(nil) +) + +const ( + ScopeListNews Scope = "news.list" +) + +func (*ListNewsScope) Scope() component.ScopeInfo { + return component.ScopeInfo{ + Scope: ScopeListNews, + Description: "list news items", + DeniedMessage: "", + TakesParam: false, + } +} + +func (lns *ListNewsScope) HasScope(param string, r *http.Request) (bool, error) { + // TODO: at the moment everyone has this permission + return true, nil +} diff --git a/internal/dis/component/auth/scopes/scopes.go b/internal/dis/component/auth/scopes/scopes.go new file mode 100644 index 0000000..b55fe4a --- /dev/null +++ b/internal/dis/component/auth/scopes/scopes.go @@ -0,0 +1,6 @@ +// Package scopes implements and provides scopes used by the API +package scopes + +import "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + +type Scope = component.Scope diff --git a/internal/dis/component/auth/scopes/user.go b/internal/dis/component/auth/scopes/user.go index 6d055d5..97c62ec 100644 --- a/internal/dis/component/auth/scopes/user.go +++ b/internal/dis/component/auth/scopes/user.go @@ -18,9 +18,13 @@ var ( _ component.ScopeProvider = (*UserLoggedIn)(nil) ) +const ( + ScopeUserLoggedIn Scope = "login.user" +) + func (*UserLoggedIn) Scope() component.ScopeInfo { return component.ScopeInfo{ - Scope: component.ScopeUserLoggedIn, + Scope: ScopeUserLoggedIn, Description: "session has an associated user", TakesParam: false, } diff --git a/internal/dis/component/resolver/resolver.go b/internal/dis/component/resolver/resolver.go index df9353d..76da377 100644 --- a/internal/dis/component/resolver/resolver.go +++ b/internal/dis/component/resolver/resolver.go @@ -11,6 +11,7 @@ import ( "github.com/FAU-CDI/wdresolve/resolvers" "github.com/FAU-CDI/wisski-distillery/internal/dis/component" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/assets" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templating" @@ -102,7 +103,7 @@ func (resolver *Resolver) HandleRoute(ctx context.Context, route string) (http.H IndexContext: context, } - if resolver.Dependencies.Auth.CheckScope("", component.ScopeUserLoggedIn, r) != nil { + if resolver.Dependencies.Auth.CheckScope("", scopes.ScopeUserLoggedIn, r) != nil { ctx.IndexContext.Prefixes = nil } httpx.WriteHTML(tpl.Context(r, ctx), nil, t, "", w, r) diff --git a/internal/dis/component/scope.go b/internal/dis/component/scope.go index 08e2489..9912265 100644 --- a/internal/dis/component/scope.go +++ b/internal/dis/component/scope.go @@ -51,11 +51,6 @@ func (scope ScopeInfo) CheckError(err error) error { return CheckError{Scope: scope.Scope, Err: err} } -const ( - ScopeUserLoggedIn Scope = "login.user" - ScopeAdminLoggedIn Scope = "login.admin" -) - // ScopeProvider is a component that can check a specific scope type ScopeProvider interface { Component diff --git a/internal/dis/component/server/admin/admin.go b/internal/dis/component/server/admin/admin.go index 14e1c00..ece8512 100644 --- a/internal/dis/component/server/admin/admin.go +++ b/internal/dis/component/server/admin/admin.go @@ -7,6 +7,7 @@ import ( "github.com/FAU-CDI/wisski-distillery/internal/dis/component" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/policy" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/admin/socket" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templating" "github.com/julienschmidt/httprouter" @@ -46,12 +47,12 @@ func (admin *Admin) Routes() component.Routes { return component.Routes{ Prefix: "/admin/", CSRF: true, - Decorator: admin.Dependencies.Auth.Require(component.ScopeAdminLoggedIn, nil), + Decorator: admin.Dependencies.Auth.Require(scopes.ScopeAdminLoggedIn, nil), } } func (admin *Admin) Menu(r *http.Request) []component.MenuItem { - if admin.Dependencies.Auth.CheckScope("", component.ScopeAdminLoggedIn, r) != nil { + if admin.Dependencies.Auth.CheckScope("", scopes.ScopeAdminLoggedIn, r) != nil { return nil } return []component.MenuItem{ diff --git a/internal/dis/component/server/home/api.go b/internal/dis/component/server/home/api.go deleted file mode 100644 index cfb6439..0000000 --- a/internal/dis/component/server/home/api.go +++ /dev/null @@ -1,64 +0,0 @@ -package home - -import ( - "context" - "net/http" - "time" - - "github.com/FAU-CDI/wisski-distillery/internal/dis/component" - "github.com/FAU-CDI/wisski-distillery/internal/status" - "github.com/tkw1536/pkglib/httpx" -) - -type API struct { - component.Base - - Dependencies struct { - Home *Home - } -} - -var ( - _ component.Routeable = (*API)(nil) -) - -func (api *API) Routes() component.Routes { - return component.Routes{ - Prefix: "/api/v1/systems", - Exact: true, - Decorator: api.Config.HTTP.APIDecorator("GET"), - } -} - -type APISystemInfo struct { - Slug string - URL string - Tagline string - - EntityCount int - BundleCount int - LastEdit time.Time -} - -func (api *API) HandleRoute(ctx context.Context, path string) (http.Handler, error) { - return httpx.JSON(func(r *http.Request) ([]APISystemInfo, error) { - var statuses []status.WissKI - if api.Dependencies.Home.ShouldShowList(r) { - statuses = api.Dependencies.Home.homeInstances.Get(nil) - } - - if len(statuses) == 0 { - return []APISystemInfo{}, nil - } - - infos := make([]APISystemInfo, len(statuses)) - for i, status := range statuses { - infos[i].Slug = status.Slug - infos[i].URL = status.URL - infos[i].EntityCount = status.Statistics.Bundles.TotalCount() - infos[i].BundleCount = status.Statistics.Bundles.TotalBundles - infos[i].LastEdit = status.Statistics.Bundles.LastEdit().Time - } - return infos, nil - }), nil -} diff --git a/internal/dis/component/server/home/home.go b/internal/dis/component/server/home/home.go index 3643c0a..4fab5b6 100644 --- a/internal/dis/component/server/home/home.go +++ b/internal/dis/component/server/home/home.go @@ -6,23 +6,16 @@ import ( "net/http" "github.com/FAU-CDI/wisski-distillery/internal/dis/component" - "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" - "github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/list" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templating" - "github.com/FAU-CDI/wisski-distillery/internal/status" - "github.com/tkw1536/pkglib/lazy" ) type Home struct { component.Base Dependencies struct { - Templating *templating.Templating - Instances *instances.Instances - Auth *auth.Auth + ListInstances *list.ListInstances + Templating *templating.Templating } - - instanceNames lazy.Lazy[map[string]struct{}] // instance names - homeInstances lazy.Lazy[[]status.WissKI] // list of home instances (updated via cron) } var ( @@ -61,21 +54,8 @@ func (home *Home) HandleRoute(ctx context.Context, route string) (http.Handler, }), nil } -func (home *Home) instanceMap(ctx context.Context) (map[string]struct{}, error) { - wissKIs, err := home.Dependencies.Instances.All(ctx) - 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) serveWissKI(w http.ResponseWriter, slug string, r *http.Request) { - if _, ok := home.instanceNames.Get(nil)[slug]; !ok { + if _, ok := home.Dependencies.ListInstances.Names()[slug]; !ok { // Get(nil) guaranteed to work by precondition w.WriteHeader(http.StatusNotFound) fmt.Fprintf(w, "WissKI %q not found\n", slug) diff --git a/internal/dis/component/server/home/instances.go b/internal/dis/component/server/home/instances.go deleted file mode 100644 index 0bceca2..0000000 --- a/internal/dis/component/server/home/instances.go +++ /dev/null @@ -1,86 +0,0 @@ -package home - -import ( - "context" - - "github.com/FAU-CDI/wisski-distillery/internal/dis/component" - "github.com/FAU-CDI/wisski-distillery/internal/status" - "golang.org/x/sync/errgroup" -) - -// loadInstances loads all the instances into the home route -func (home *Home) loadInstances(ctx context.Context) ([]status.WissKI, error) { - // find all the WissKIs - wissKIs, err := home.Dependencies.Instances.All(ctx) - if err != nil { - return nil, err - } - - instances := make([]status.WissKI, len(wissKIs)) - - // determine their infos - var eg errgroup.Group - for i, instance := range wissKIs { - i := i - wissKI := instance - eg.Go(func() (err error) { - instances[i], err = wissKI.Info().Information(ctx, false) - return - }) - } - eg.Wait() - - // and return the new instances - return instances, nil -} - -// UpdateInstanceList updates the instances list of the home struct -type UpdateInstanceList struct { - component.Base - Dependencies struct { - Home *Home - } -} - -var ( - _ component.Cronable = (*UpdateInstanceList)(nil) -) - -func (*UpdateInstanceList) TaskName() string { - return "instance list" -} - -func (ul *UpdateInstanceList) Cron(ctx context.Context) error { - names, err := ul.Dependencies.Home.instanceMap(ctx) - if err != nil { - return err - } - - ul.Dependencies.Home.instanceNames.Set(names) - return nil -} - -type UpdateHome struct { - component.Base - Dependencies struct { - Home *Home - } -} - -var ( - _ component.Cronable = (*UpdateHome)(nil) -) - -func (ur *UpdateHome) TaskName() string { - return "home instances fetch" -} - -func (ur *UpdateHome) Cron(ctx context.Context) error { - instances, err := ur.Dependencies.Home.loadInstances(ctx) - if err != nil { - return err - } - - ur.Dependencies.Home.homeInstances.Set(instances) - return nil -} diff --git a/internal/dis/component/server/home/public.go b/internal/dis/component/server/home/public.go index e0bf880..7b669c8 100644 --- a/internal/dis/component/server/home/public.go +++ b/internal/dis/component/server/home/public.go @@ -47,23 +47,6 @@ type publicContext struct { const logoHTML = template.HTML(``) -// ShouldShowList determines if the given request should show a WissKI list -func (home *Home) ShouldShowList(r *http.Request) bool { - allowPrivate := home.Config.Home.List.Private.Value - allowPublic := home.Config.Home.List.Public.Value - - if allowPrivate == allowPublic { - return allowPrivate - } - - user, _ := home.Dependencies.Auth.UserOf(r) - if user == nil { - return allowPublic - } else { - return allowPrivate - } -} - func (home *Home) publicHandler(ctx context.Context) http.Handler { title := home.Config.Home.Title @@ -89,7 +72,7 @@ func (home *Home) publicHandler(ctx context.Context) http.Handler { // prepare about pc.aboutContext.Logo = logoHTML - pc.aboutContext.Instances = home.homeInstances.Get(nil) + pc.aboutContext.Instances = home.Dependencies.ListInstances.Infos() pc.aboutContext.SelfRedirect = home.Config.Home.SelfRedirect.String() // render the about template @@ -102,7 +85,7 @@ func (home *Home) publicHandler(ctx context.Context) http.Handler { pc.About = template.HTML(builder.String()) // check if we should show the list of WissKIs - pc.ListEnabled = home.ShouldShowList(r) + pc.ListEnabled = home.Dependencies.ListInstances.ShouldShowList(r) // title of the list pc.ListTitle = home.Config.Home.List.Title diff --git a/internal/dis/component/server/home/public.html b/internal/dis/component/server/home/public.html index 8712032..b5a5ac9 100644 --- a/internal/dis/component/server/home/public.html +++ b/internal/dis/component/server/home/public.html @@ -6,22 +6,20 @@ {{range .Instances}} - {{ if and .Running (not .NoPrefixes) }} -
-

{{.Slug}}

-

- {{.URL}}
- - {{ .Statistics.Bundles.Summary }} +

+

{{.Slug}}

+

+ {{.URL}}
+ + {{ .Statistics.Bundles.Summary }} - {{ $edit := .Statistics.Bundles.LastEdit }} - {{ if $edit.Valid }} -
- last edited {{ $edit.Time.Format "2006-01-02T15:04:05Z07:00" }} - {{ end }} -
-

-
- {{ end }} + {{ $edit := .Statistics.Bundles.LastEdit }} + {{ if $edit.Valid }} +
+ last edited {{ $edit.Time.Format "2006-01-02T15:04:05Z07:00" }} + {{ end }} +
+

+
{{ end }} {{ end }} \ No newline at end of file diff --git a/internal/dis/component/server/list/api.go b/internal/dis/component/server/list/api.go new file mode 100644 index 0000000..b9124fc --- /dev/null +++ b/internal/dis/component/server/list/api.go @@ -0,0 +1,71 @@ +package list + +import ( + "context" + "net/http" + "time" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/api" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes" + "github.com/FAU-CDI/wisski-distillery/internal/status" +) + +// API implements an API to list all instances +type API struct { + component.Base + Dependencies struct { + ListInstances *ListInstances + Auth *auth.Auth + } +} + +func (lia *API) Routes() component.Routes { + return component.Routes{ + Prefix: "/api/v1/instances/directory", + Exact: true, + } +} + +// APISystem represents a system returned by the api +type APISystem struct { + Slug string + URL string + Tagline string + + EntityCount int + BundleCount int + LastEdit time.Time +} + +func (a *API) HandleRoute(ctx context.Context, path string) (http.Handler, error) { + return &api.Handler[[]APISystem]{ + Config: a.Config, + Auth: a.Dependencies.Auth, + + Methods: []string{"GET"}, + Scope: scopes.ScopeInstanceDirectory, + + Handler: func(s string, r *http.Request) ([]APISystem, error) { + var statuses []status.WissKI + if a.Dependencies.ListInstances.ShouldShowList(r) { + statuses = a.Dependencies.ListInstances.infos.Get(nil) + } + + if len(statuses) == 0 { + return []APISystem{}, nil + } + + infos := make([]APISystem, len(statuses)) + for i, status := range statuses { + infos[i].Slug = status.Slug + infos[i].URL = status.URL + infos[i].EntityCount = status.Statistics.Bundles.TotalCount() + infos[i].BundleCount = status.Statistics.Bundles.TotalBundles + infos[i].LastEdit = status.Statistics.Bundles.LastEdit().Time + } + return infos, nil + }, + }, nil +} diff --git a/internal/dis/component/server/list/list.go b/internal/dis/component/server/list/list.go new file mode 100644 index 0000000..45962b9 --- /dev/null +++ b/internal/dis/component/server/list/list.go @@ -0,0 +1,130 @@ +package list + +import ( + "context" + "net/http" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances" + "github.com/FAU-CDI/wisski-distillery/internal/status" + "github.com/tkw1536/pkglib/lazy" + "golang.org/x/sync/errgroup" +) + +// ListInstances holds information about all instances +type ListInstances struct { + component.Base + + names lazy.Lazy[map[string]struct{}] // instance names + infos lazy.Lazy[[]status.WissKI] // list of home instances (updated via cron) + + Dependencies struct { + Auth *auth.Auth + Instances *instances.Instances + } +} + +func (li *ListInstances) Names() map[string]struct{} { + return li.names.Get(nil) +} + +func (li *ListInstances) Infos() []status.WissKI { + return li.infos.Get(nil) +} + +// ShouldShowList determines if a list should be shown for the given request +func (li *ListInstances) ShouldShowList(r *http.Request) bool { + allowPrivate := li.Config.Home.List.Private.Value + allowPublic := li.Config.Home.List.Public.Value + + if allowPrivate == allowPublic { + return allowPrivate + } + + user, _ := li.Dependencies.Auth.UserOf(r) + if user == nil { + return allowPublic + } else { + return allowPrivate + } +} + +var ( + _ component.Cronable = (*ListInstances)(nil) +) + +func (li *ListInstances) TaskName() string { + return "instance list and status" +} + +func (li *ListInstances) Cron(ctx context.Context) (err error) { + { + names, e := li.getNames(ctx) + if err == nil { + li.names.Set(names) + } else { + err = e + } + } + + { + infos, e := li.getInfos(ctx) + if err == nil { + li.infos.Set(infos) + } else { + err = e + } + } + + return +} + +// getNames returns the names of the given instances +func (li *ListInstances) getNames(ctx context.Context) (map[string]struct{}, error) { + wissKIs, err := li.Dependencies.Instances.All(ctx) + if err != nil { + return nil, err + } + + names := make(map[string]struct{}, len(wissKIs)) + for _, w := range wissKIs { + names[w.Slug] = struct{}{} + } + return names, nil +} + +// getInfos returns the names of the given instances +func (li *ListInstances) getInfos(ctx context.Context) ([]status.WissKI, error) { + // find all the WissKIs + wissKIs, err := li.Dependencies.Instances.All(ctx) + if err != nil { + return nil, err + } + + infos := make([]status.WissKI, len(wissKIs)) + + // determine their infos + var eg errgroup.Group + for i, instance := range wissKIs { + i := i + wissKI := instance + eg.Go(func() (err error) { + infos[i], err = wissKI.Info().Information(ctx, false) + return + }) + } + eg.Wait() + + // filter them by those that are running and do not have prefixes excluded + infosF := infos[:0] + for _, info := range infos { + if info.NoPrefixes || !info.Running { + continue + } + infosF = append(infosF, info) + } + + // and return them + return infos[:len(infosF):len(infosF)], nil +} diff --git a/internal/dis/component/server/news/api.go b/internal/dis/component/server/news/api.go index 5f08984..404250f 100644 --- a/internal/dis/component/server/news/api.go +++ b/internal/dis/component/server/news/api.go @@ -2,15 +2,19 @@ package news import ( "context" - "encoding/json" "net/http" "github.com/FAU-CDI/wisski-distillery/internal/dis/component" - "github.com/tkw1536/pkglib/httpx" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/api" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes" ) type API struct { component.Base + Dependencies struct { + Auth *auth.Auth + } } var ( @@ -19,25 +23,21 @@ var ( func (api *API) Routes() component.Routes { return component.Routes{ - Prefix: "/api/v1/news/", - Exact: true, - Decorator: api.Config.HTTP.APIDecorator("GET"), + Prefix: "/api/v1/news/", + Exact: true, } } -func (api *API) HandleRoute(ctx context.Context, path string) (http.Handler, error) { - items, err := Items() - if err != nil { - return nil, err - } +func (a *API) HandleRoute(ctx context.Context, path string) (http.Handler, error) { + return &api.Handler[[]Item]{ + Config: a.Config, + Auth: a.Dependencies.Auth, - data, err := json.Marshal(items) - if err != nil { - return nil, err - } + Methods: []string{"GET"}, - return httpx.Response{ - ContentType: "application/json", - Body: data, + Scope: scopes.ScopeListNews, + Handler: func(s string, r *http.Request) ([]Item, error) { + return Items() + }, }, nil } diff --git a/internal/dis/distillery.go b/internal/dis/distillery.go index 6bf5606..72af7ee 100644 --- a/internal/dis/distillery.go +++ b/internal/dis/distillery.go @@ -29,6 +29,7 @@ import ( "github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/cron" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/home" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/legal" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/list" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/logo" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/news" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templating" @@ -148,6 +149,8 @@ func (dis *Distillery) allComponents() []initFunc { //scopes auto[*scopes.UserLoggedIn], auto[*scopes.AdminLoggedIn], + auto[*scopes.ListInstancesScope], + auto[*scopes.ListNewsScope], // instances auto[*instances.Instances], @@ -174,6 +177,7 @@ func (dis *Distillery) allComponents() []initFunc { auto[*server.Server], auto[*home.Home], + auto[*list.ListInstances], manual(func(resolver *resolver.Resolver) { resolver.RefreshInterval = time.Minute }), @@ -190,11 +194,9 @@ func (dis *Distillery) allComponents() []initFunc { // Cron auto[*cron.Cron], - auto[*home.UpdateHome], - auto[*home.UpdateInstanceList], // API - auto[*home.API], + auto[*list.API], auto[*news.API], } }