Refactor server and templates package
This commit is contained in:
parent
b6bf0a8900
commit
6ede99d7c6
105 changed files with 341 additions and 339 deletions
163
internal/dis/component/server/admin/admin.go
Normal file
163
internal/dis/component/server/admin/admin.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
package admin
|
||||
|
||||
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/auth/policy"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter/logger"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances/purger"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templates"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/lazy"
|
||||
)
|
||||
|
||||
type Admin struct {
|
||||
component.Base
|
||||
Dependencies struct {
|
||||
Fetchers []component.DistilleryFetcher
|
||||
|
||||
Exporter *exporter.Exporter
|
||||
Instances *instances.Instances
|
||||
SnapshotsLog *logger.Logger
|
||||
|
||||
Auth *auth.Auth
|
||||
|
||||
Policy *policy.Policy
|
||||
|
||||
Templating *templates.Templating
|
||||
|
||||
Purger *purger.Purger
|
||||
}
|
||||
|
||||
Analytics *lazy.PoolAnalytics
|
||||
}
|
||||
|
||||
var (
|
||||
_ component.DistilleryFetcher = (*Admin)(nil)
|
||||
_ component.Routeable = (*Admin)(nil)
|
||||
_ component.Menuable = (*Admin)(nil)
|
||||
)
|
||||
|
||||
func (admin *Admin) Routes() component.Routes {
|
||||
return component.Routes{
|
||||
Prefix: "/admin/",
|
||||
CSRF: true,
|
||||
Decorator: admin.Dependencies.Auth.Require(auth.Admin),
|
||||
}
|
||||
}
|
||||
|
||||
func (admin *Admin) Menu(r *http.Request) []component.MenuItem {
|
||||
if !admin.Dependencies.Auth.Has(auth.Admin, r) {
|
||||
return nil
|
||||
}
|
||||
return []component.MenuItem{
|
||||
{
|
||||
Title: "Admin",
|
||||
Path: "/admin/",
|
||||
Priority: component.MenuAdmin,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (admin *Admin) HandleRoute(ctx context.Context, route string) (handler http.Handler, err error) {
|
||||
|
||||
router := httprouter.New()
|
||||
|
||||
{
|
||||
handler = &httpx.WebSocket{
|
||||
Context: ctx,
|
||||
Fallback: router,
|
||||
Handler: admin.serveSocket,
|
||||
}
|
||||
}
|
||||
|
||||
// add a handler for the index page
|
||||
{
|
||||
index := admin.index(ctx)
|
||||
router.Handler(http.MethodGet, route, index)
|
||||
}
|
||||
|
||||
// add a handler for the user page
|
||||
{
|
||||
users := admin.users(ctx)
|
||||
router.Handler(http.MethodGet, route+"users", users)
|
||||
}
|
||||
|
||||
// add a user create form
|
||||
{
|
||||
create := admin.createUser(ctx)
|
||||
router.Handler(http.MethodGet, route+"users/create", create)
|
||||
router.Handler(http.MethodPost, route+"users/create", create)
|
||||
}
|
||||
|
||||
// add all the admin actions
|
||||
router.Handler(http.MethodPost, route+"users/delete", admin.usersDeleteHandler(ctx))
|
||||
router.Handler(http.MethodPost, route+"users/disable", admin.usersDisableHandler(ctx))
|
||||
router.Handler(http.MethodPost, route+"users/disabletotp", admin.usersDisableTOTPHandler(ctx))
|
||||
router.Handler(http.MethodPost, route+"users/password", admin.usersPasswordHandler(ctx))
|
||||
router.Handler(http.MethodPost, route+"users/toggleadmin", admin.usersToggleAdmin(ctx))
|
||||
router.Handler(http.MethodPost, route+"users/impersonate", admin.usersImpersonateHandler(ctx))
|
||||
router.Handler(http.MethodPost, route+"users/unsetpassword", admin.usersUnsetPasswordHandler(ctx))
|
||||
|
||||
// add a handler for the component page
|
||||
{
|
||||
components := admin.components(ctx)
|
||||
router.Handler(http.MethodGet, route+"components", components)
|
||||
}
|
||||
|
||||
// add a handler for the ingredients page
|
||||
{
|
||||
ingredients := admin.ingredients(ctx)
|
||||
router.Handler(http.MethodGet, route+"ingredients/:slug", ingredients)
|
||||
}
|
||||
|
||||
// add a handler for the instance page
|
||||
{
|
||||
instance := admin.instance(ctx)
|
||||
router.Handler(http.MethodGet, route+"instance/:slug", instance)
|
||||
}
|
||||
|
||||
{
|
||||
grants := admin.grants(ctx)
|
||||
router.Handler(http.MethodGet, route+"grants/:slug", grants)
|
||||
router.Handler(http.MethodPost, route+"grants/", grants) // NOTE(twiesing): This path is intentionally different!
|
||||
}
|
||||
|
||||
// add a router for the login page
|
||||
router.Handler(http.MethodPost, route+"login", admin.loginHandler(ctx))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (admin *Admin) loginHandler(ctx context.Context) http.Handler {
|
||||
logger := zerolog.Ctx(ctx)
|
||||
|
||||
return httpx.RedirectHandler(func(r *http.Request) (string, int, error) {
|
||||
// parse the form
|
||||
if err := r.ParseForm(); err != nil {
|
||||
logger.Err(err).Msg("failed to parse admin login")
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
// get the instance
|
||||
instance, err := admin.Dependencies.Instances.WissKI(r.Context(), r.PostFormValue("slug"))
|
||||
if err != nil {
|
||||
return "", 0, httpx.ErrNotFound
|
||||
}
|
||||
|
||||
target, err := instance.Users().Login(r.Context(), nil, r.PostFormValue("user"))
|
||||
if err != nil {
|
||||
logger.Err(err).Msg("failed to admin login")
|
||||
return "", 0, err
|
||||
}
|
||||
return target.String(), http.StatusSeeOther, err
|
||||
})
|
||||
}
|
||||
85
internal/dis/component/server/admin/components.go
Normal file
85
internal/dis/component/server/admin/components.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"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/templates"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/models"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/lazy"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
//go:embed "html/components.html"
|
||||
var componentsHTML []byte
|
||||
var componentsTemplate = templates.Parse[componentContext]("components.html", componentsHTML, assets.AssetsAdmin)
|
||||
|
||||
type componentContext struct {
|
||||
templates.BaseContext
|
||||
|
||||
Analytics lazy.PoolAnalytics
|
||||
}
|
||||
|
||||
func (admin *Admin) components(ctx context.Context) http.Handler {
|
||||
tpl := componentsTemplate.Prepare(admin.Dependencies.Templating, templates.BaseContextGaps{
|
||||
Crumbs: []component.MenuItem{
|
||||
{Title: "Admin", Path: "/admin/"},
|
||||
{Title: "Components", Path: "/admin/components/"},
|
||||
},
|
||||
})
|
||||
|
||||
return tpl.HTMLHandler(func(r *http.Request) (cp componentContext, err error) {
|
||||
cp.Analytics = *admin.Analytics
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
//go:embed "html/ingredients.html"
|
||||
var ingredientsHTML []byte
|
||||
var ingredientsTemplate = templates.Parse[ingredientsContext]("ingredients.html", ingredientsHTML, assets.AssetsAdmin)
|
||||
|
||||
type ingredientsContext struct {
|
||||
templates.BaseContext
|
||||
|
||||
Instance models.Instance
|
||||
Analytics *lazy.PoolAnalytics
|
||||
}
|
||||
|
||||
func (admin *Admin) ingredients(ctx context.Context) http.Handler {
|
||||
tpl := ingredientsTemplate.Prepare(admin.Dependencies.Templating, templates.BaseContextGaps{
|
||||
Crumbs: []component.MenuItem{
|
||||
{Title: "Admin", Path: "/admin/"},
|
||||
{Title: "Instance", Path: "* to be updated *"},
|
||||
{Title: "Ingredients", Path: "* to be updated *"},
|
||||
},
|
||||
})
|
||||
|
||||
return tpl.HTMLHandlerWithGaps(func(r *http.Request, gaps *templates.BaseContextGaps) (ic ingredientsContext, err error) {
|
||||
slug := httprouter.ParamsFromContext(r.Context()).ByName("slug")
|
||||
|
||||
gaps.Crumbs[1] = component.MenuItem{Title: "Instance", Path: template.URL("/admin/instance/" + slug)}
|
||||
gaps.Crumbs[2] = component.MenuItem{Title: "Ingredients", Path: template.URL("/admin/instance/" + slug + "/ingredients/")}
|
||||
|
||||
// find the instance itself!
|
||||
instance, err := admin.Dependencies.Instances.WissKI(r.Context(), slug)
|
||||
if err == instances.ErrWissKINotFound {
|
||||
return ic, httpx.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return ic, err
|
||||
}
|
||||
ic.Instance = instance.Instance
|
||||
|
||||
// and get the components
|
||||
ic.Analytics = instance.Info().Analytics
|
||||
|
||||
return
|
||||
})
|
||||
}
|
||||
173
internal/dis/component/server/admin/grants.go
Normal file
173
internal/dis/component/server/admin/grants.go
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"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/templates"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/models"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx/field"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
//go:embed "html/grants.html"
|
||||
var grantsHTML []byte
|
||||
var grantsTemplate = templates.Parse[grantsContext]("grants.html", grantsHTML, assets.AssetsAdmin)
|
||||
|
||||
type grantsContext struct {
|
||||
templates.BaseContext
|
||||
|
||||
Error string
|
||||
|
||||
instance *wisski.WissKI
|
||||
Instance models.Instance // current instance
|
||||
|
||||
Grants []models.Grant // grants that exist for the user
|
||||
Usernames []string // unuused distillery usernames
|
||||
Drupals []string // unusued drupal usernames
|
||||
}
|
||||
|
||||
func (admin *Admin) grants(ctx context.Context) http.Handler {
|
||||
tpl := grantsTemplate.Prepare(admin.Dependencies.Templating, templates.BaseContextGaps{
|
||||
Crumbs: []component.MenuItem{
|
||||
{Title: "Admin", Path: "/admin/"},
|
||||
{Title: "Instance", Path: "*to be updated*"},
|
||||
{Title: "Grants", Path: "*to be updated*"},
|
||||
},
|
||||
})
|
||||
|
||||
return tpl.HTMLHandlerWithGaps(func(r *http.Request, gaps *templates.BaseContextGaps) (grantsContext, error) {
|
||||
if r.Method == http.MethodGet {
|
||||
return admin.getGrants(r, gaps)
|
||||
} else {
|
||||
return admin.postGrants(r, gaps)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (admin *Admin) getGrants(r *http.Request, gaps *templates.BaseContextGaps) (gc grantsContext, err error) {
|
||||
slug := httprouter.ParamsFromContext(r.Context()).ByName("slug")
|
||||
if err := gc.use(r, gaps, slug, admin); err != nil {
|
||||
return gc, err
|
||||
}
|
||||
|
||||
if err := gc.useGrants(r, admin); err != nil {
|
||||
return gc, err
|
||||
}
|
||||
|
||||
return gc, nil
|
||||
}
|
||||
|
||||
func (admin *Admin) postGrants(r *http.Request, gaps *templates.BaseContextGaps) (gc grantsContext, err error) {
|
||||
// parse the form
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return gc, err
|
||||
}
|
||||
|
||||
// read out the form values
|
||||
var (
|
||||
slug = r.PostFormValue("slug")
|
||||
delete = r.PostFormValue("action") == "delete"
|
||||
distilleryUser = r.PostFormValue("distillery-user")
|
||||
drupalUser = r.PostFormValue("drupal-user")
|
||||
adminRole = r.PostFormValue("admin") == field.CheckboxChecked
|
||||
)
|
||||
|
||||
// set the common fields
|
||||
if err := gc.use(r, gaps, slug, admin); err != nil {
|
||||
return gc, err
|
||||
}
|
||||
|
||||
if delete {
|
||||
// delete the user grant
|
||||
err := admin.Dependencies.Policy.Remove(r.Context(), distilleryUser, slug)
|
||||
if err != nil {
|
||||
return gc, err
|
||||
}
|
||||
} else {
|
||||
// update the grant
|
||||
err := admin.Dependencies.Policy.Set(r.Context(), models.Grant{
|
||||
User: distilleryUser,
|
||||
Slug: slug,
|
||||
|
||||
DrupalUsername: drupalUser,
|
||||
DrupalAdminRole: adminRole,
|
||||
})
|
||||
if err != nil {
|
||||
gc.Error = fmt.Sprintf("Unable to update grant for user %s: %s", distilleryUser, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// fetch the grants for the instance
|
||||
if err := gc.useGrants(r, admin); err != nil {
|
||||
return gc, err
|
||||
}
|
||||
return gc, nil
|
||||
}
|
||||
|
||||
func (gc *grantsContext) use(r *http.Request, gaps *templates.BaseContextGaps, slug string, admin *Admin) (err error) {
|
||||
gaps.Crumbs[1] = component.MenuItem{Title: "Instance", Path: template.URL("/admin/instance/" + slug)}
|
||||
gaps.Crumbs[2] = component.MenuItem{Title: "Grants", Path: template.URL("/admin/instance/" + slug + "/grants/")}
|
||||
|
||||
// find the instance itself
|
||||
gc.instance, err = admin.Dependencies.Instances.WissKI(r.Context(), slug)
|
||||
if err == instances.ErrWissKINotFound {
|
||||
return httpx.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gc.Instance = gc.instance.Instance
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gc *grantsContext) useGrants(r *http.Request, admin *Admin) (err error) {
|
||||
gc.Grants, err = admin.Dependencies.Policy.Instance(r.Context(), gc.Instance.Slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
users, err := admin.Dependencies.Auth.Users(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create a namemap of users, but not those already taken
|
||||
userNameMap := make(map[string]struct{}, len(users))
|
||||
for _, user := range users {
|
||||
userNameMap[user.User.User] = struct{}{}
|
||||
}
|
||||
for _, grant := range gc.Grants {
|
||||
delete(userNameMap, grant.User)
|
||||
}
|
||||
|
||||
// setup the usernames
|
||||
gc.Usernames = maps.Keys(userNameMap)
|
||||
slices.Sort(gc.Usernames)
|
||||
|
||||
// get the drupal usernames
|
||||
drupals, err := gc.instance.Users().All(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// and convert them to strings only
|
||||
gc.Drupals = make([]string, len(drupals))
|
||||
for i, drupal := range drupals {
|
||||
gc.Drupals[i] = string(drupal.Name)
|
||||
}
|
||||
slices.Sort(gc.Drupals)
|
||||
|
||||
return nil
|
||||
}
|
||||
6
internal/dis/component/server/admin/html/components.html
Normal file
6
internal/dis/component/server/admin/html/components.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{{ template "_base.html" . }}
|
||||
{{ define "title" }}Components{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
{{ template "_anal.html" .Analytics }}
|
||||
{{ end }}
|
||||
127
internal/dis/component/server/admin/html/grants.html
Normal file
127
internal/dis/component/server/admin/html/grants.html
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
{{ template "_base.html" . }}
|
||||
{{ define "title" }}{{ .Instance.Slug }} - Grants{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
{{ $csrf := .CSRF }}
|
||||
{{ $slug := .Instance.Slug }}
|
||||
<div class="pure-u-1-1">
|
||||
<h2 id="overview">Grants</h2>
|
||||
|
||||
<p>
|
||||
A grant provides access for a specific distillery user to a specific WissKI instance.
|
||||
Only <em>Distillery Administrators</em> can manage grants.
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<em>Distillery Users</em> must exist in order to grant them access to a specific instance
|
||||
</li>
|
||||
<li>
|
||||
<em>Drupal Users</em> will be automatically created if they do not exist.
|
||||
</li>
|
||||
<li>
|
||||
If <em>Admin</em> is checked and a user logs in, they will automatically be given the admin role.
|
||||
For security reasons, an admin role is never automatically removed.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{{ block "form/message" . }}
|
||||
{{ $E := .Error }}
|
||||
{{ if not (eq $E "") }}
|
||||
<div class="pure-form-group">
|
||||
<p class="error-message">
|
||||
{{ $E }}
|
||||
</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="pure-u-1">
|
||||
<div class="padding">
|
||||
<div class="overflow">
|
||||
<table class="pure-table pure-table-bordered pure-form">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Distillery Username
|
||||
</th>
|
||||
<th>
|
||||
Drupal Username
|
||||
</th>
|
||||
<th>
|
||||
Roles
|
||||
</th>
|
||||
<th>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range $id, $grant := .Grants }}
|
||||
<tr>
|
||||
<td>
|
||||
{{ $grant.User }}
|
||||
<input type="hidden" name="distillery-user" value="{{ $grant.User }}" form="update-{{ $id }}">
|
||||
<input type="hidden" name="distillery-user" value="{{ $grant.User }}" form="delete-{{ $id }}">
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" name="drupal-user" list="drupal-users" value="{{ $grant.DrupalUsername }}" form="update-{{ $id }}">
|
||||
</td>
|
||||
<td>
|
||||
<label for="update={{ $id }}-admin">Admin</label>
|
||||
<input type="checkbox" name="admin" id="update-{{ $id }}-admin" {{ if $grant.DrupalAdminRole }}checked{{end}} form="update-{{ $id }}">
|
||||
</td>
|
||||
<td>
|
||||
<div class="pure-button-group" role="group">
|
||||
<form id="update-{{ $id }}" method="POST" action="/admin/grants/" class="pure-form-group" autocomplete="off">
|
||||
{{ $csrf }}
|
||||
<input type="hidden" name="slug" value="{{ $slug }}">
|
||||
<input type="hidden" name="action" value="update">
|
||||
<input type="submit" class="pure-button" value="Update">
|
||||
</form>
|
||||
<form id="delete-{{ $id }}" method="POST" action="/admin/grants/" class="pure-form-group" autocomplete="off">
|
||||
{{ $csrf }}
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="slug" value="{{ $slug }}">
|
||||
<input type="submit" class="pure-button pure-button-danger" value="Delete">
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="text" name="distillery-user" list="distillery-users" placeholder="Distillery User" form="add-grant">
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" name="drupal-user" list="drupal-users" placeholder="Drupal User" form="add-grant">
|
||||
</td>
|
||||
<td>
|
||||
<label form="add-grant-admin">Admin</label>
|
||||
<input type="checkbox" name="admin" id="add-grant-admin" form="add-grant">
|
||||
</td>
|
||||
<td>
|
||||
<form id="add-grant" method="POST" action="/admin/grants/" class="pure-form-group">
|
||||
{{ $csrf }}
|
||||
<input type="hidden" name="action" value="update">
|
||||
<input type="hidden" name="slug" value="{{ $slug }}">
|
||||
<input type="submit" class="pure-button" value="Add New">
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<datalist id="distillery-users">
|
||||
{{ range $unused, $user := .Usernames }}
|
||||
<option value="{{ $user }}">
|
||||
{{ end }}
|
||||
</datalist>
|
||||
<datalist id="drupal-users">
|
||||
{{ range $unused, $drupal := .Drupals }}
|
||||
<option value="{{ $drupal }}">
|
||||
{{ end }}
|
||||
</datalist>
|
||||
{{ end }}
|
||||
260
internal/dis/component/server/admin/html/index.html
Normal file
260
internal/dis/component/server/admin/html/index.html
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
{{ template "_base.html" . }}
|
||||
{{ define "title" }}Admin{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
<div class="pure-u-1-1">
|
||||
<h2 id="overview">Distillery Configuration</h2>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-xl-1-3">
|
||||
<div class="padding">
|
||||
<div class="overflow">
|
||||
<table class="pure-table pure-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2">
|
||||
Domains
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
Primary
|
||||
</td>
|
||||
<td>
|
||||
<code>{{.Config.DefaultDomain}}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Extra
|
||||
</td>
|
||||
<td>
|
||||
{{ range .Config.SelfExtraDomains }}
|
||||
<code>{{.}}</code><br />
|
||||
{{ end }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Email <small>(HTTPS)</small>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{.Config.CertbotEmail}}</code>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-xl-1-3">
|
||||
<div class="padding">
|
||||
<div class="overflow">
|
||||
<table class="pure-table pure-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2">
|
||||
Database Settings
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
MySQL User Prefix
|
||||
</td>
|
||||
<td>
|
||||
<code>{{.Config.MysqlUserPrefix}}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
MySQL Database Prefix
|
||||
</td>
|
||||
<td>
|
||||
<code>{{.Config.MysqlDatabasePrefix}}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
GraphDB User Prefix
|
||||
</td>
|
||||
<td>
|
||||
<code>{{.Config.GraphDBUserPrefix}}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
GraphDB Database Prefix
|
||||
</td>
|
||||
<td>
|
||||
<code>{{.Config.GraphDBRepoPrefix}}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Bookkeeping Database
|
||||
</td>
|
||||
<td>
|
||||
<code>{{.Config.DistilleryDatabase}}</code>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-xl-1-3">
|
||||
<div class="padding">
|
||||
<div class="overflow">
|
||||
<table class="pure-table pure-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2">
|
||||
Directory Settings
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<code>root</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{.Config.DeployRoot}}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code>config</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{.Config.ConfigPath}}</code>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-xl-2-5">
|
||||
<div class="padding">
|
||||
<div class="overflow">
|
||||
<table class="pure-table pure-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2">
|
||||
Misc Settings
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
Homepage
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{.Config.SelfRedirect}}" target="_blank" rel="noopener noreferrer">{{.Config.SelfRedirect}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Docker Network Name
|
||||
</td>
|
||||
<td>
|
||||
<code>{{.Config.DockerNetworkName}}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Backup Age
|
||||
</td>
|
||||
<td>
|
||||
<code>{{.Config.MaxBackupAge}}</code> Day(s)
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pure-u-1-1">
|
||||
<h2 id="backups">Backups</h2>
|
||||
</div>
|
||||
|
||||
<div class="pure-u-1">
|
||||
<table class="pure-table pure-table-bordered padding">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Path</th>
|
||||
<th>Created</th>
|
||||
<th>Packed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Backups }}
|
||||
<tr>
|
||||
<td>
|
||||
<code class="path">{{ .Path }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code class="date">{{ .Created.Format "2006-01-02T15:04:05Z07:00" }}</code>
|
||||
</td>
|
||||
<td>
|
||||
{{ .Packed }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pure-u-1">
|
||||
<h2 id="instances">Instances</h2>
|
||||
|
||||
<table class="pure-table pure-table-bordered padding">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Total</th>
|
||||
<th>Running</th>
|
||||
<th>Stopped</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
{{ .TotalCount }}
|
||||
</td>
|
||||
<td>
|
||||
{{ .RunningCount }}
|
||||
</td>
|
||||
<td>
|
||||
{{ .StoppedCount }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<span class="hspace"></span>
|
||||
</div>
|
||||
|
||||
{{range .Instances}}
|
||||
<div class="pure-u-1 pure-u-xl-1-3">
|
||||
<div class="wisski {{ if .Running }}running{{ else }}stopped{{ end }}">
|
||||
<h3>
|
||||
{{.Slug}}
|
||||
{{ if not .Running }} <small>not running</small>{{ end }}
|
||||
</h3>
|
||||
<p>
|
||||
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.URL}}</a><br>
|
||||
|
||||
<a class="pure-button" href="/admin/instance/{{.Slug}}">Details</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{ end }}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{{ template "_base.html" . }}
|
||||
{{ define "title" }}{{ .Instance.Slug }} - Ingredients{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
{{ template "_anal.html" .Analytics }}
|
||||
{{ end }}
|
||||
488
internal/dis/component/server/admin/html/instance.html
Normal file
488
internal/dis/component/server/admin/html/instance.html
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
{{ template "_base.html" . }}
|
||||
{{ define "title" }}{{ .Instance.Slug }}{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
<div class="pure-u-1-1">
|
||||
<h2 id="overview">Info & Status</h2>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-xl-2-5">
|
||||
<div class="padding">
|
||||
<div class="overflow">
|
||||
<table class="pure-table pure-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2">
|
||||
Overview
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
Slug
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ .Info.Slug }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
URL
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ .Info.URL }}" target="_blank" rel="noopener noreferrer">{{ .Info.URL }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Running
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ .Info.Running }}</code>
|
||||
<div class="pure-button-group" role="group">
|
||||
<button class="remote-action pure-button pure-button-action" data-action="start" data-param="{{ .Instance.Slug }}" data-buffer="1000" data-force-reload>
|
||||
(Re)Start
|
||||
</button>
|
||||
<button class="remote-action pure-button pure-button-danger" data-action="stop" data-param="{{ .Instance.Slug }}" data-buffer="1000" data-force-reload>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Locked
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ .Info.Locked }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-xl-2-5">
|
||||
<div class="padding">
|
||||
<div class="overflow">
|
||||
<table class="pure-table pure-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2">
|
||||
Component Settings
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
Directory
|
||||
</td>
|
||||
<td>
|
||||
<code style="overflow: auto;">{{ .Instance.FilesystemBase }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
SQL DB
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ .Instance.SqlDatabase }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
SQL User
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ .Instance.SqlUsername }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
TS Repo
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ .Instance.GraphDBRepository }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
TS User
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ .Instance.GraphDBUsername }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pure-u-1 pure-u-xl-2-5">
|
||||
<div class="padding">
|
||||
<div class="overflow">
|
||||
<table class="pure-table pure-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2">
|
||||
Build Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
Created
|
||||
</td>
|
||||
<td>
|
||||
<code class="date">{{ .Instance.Created.Format "2006-01-02T15:04:05Z07:00" }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Last Rebuild <br>
|
||||
<button class="remote-action pure-button pure-button-action" data-action="rebuild" data-param="{{ .Instance.Slug }}" data-buffer="1000" data-force-reload>Rebuild</button>
|
||||
</td>
|
||||
<td>
|
||||
<code class="date">{{ .Info.LastRebuild.Format "2006-01-02T15:04:05Z07:00" }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Last Cron<br>
|
||||
<button class="remote-action pure-button pure-button-action" data-action="cron" data-param="{{ .Instance.Slug }}" data-buffer="1000" data-force-reload>Cron</button>
|
||||
</td>
|
||||
<td>
|
||||
<code class="date">{{ .Info.LastRebuild.Format "2006-01-02T15:04:05Z07:00" }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Last Update <br>
|
||||
<button class="remote-action pure-button pure-button-action" data-action="update" data-param="{{ .Instance.Slug }}" data-buffer="1000" data-force-reload>Update</button>
|
||||
</td>
|
||||
<td>
|
||||
<code class="date">{{ .Info.LastUpdate.Format "2006-01-02T15:04:05Z07:00" }}</code><br>
|
||||
(Automatic: <code>{{ .Instance.AutoBlindUpdateEnabled }}</code>)
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="pure-u-1-1">
|
||||
<h2 id="wisski">Users (Drupal)</h2>
|
||||
<a class="pure-button" href="/admin/grants/{{ .Info.Slug }}">Manage Grants</a>
|
||||
</div>
|
||||
|
||||
<div class="pure-u-1">
|
||||
<div class="padding">
|
||||
<div class="overflow">
|
||||
<table class="pure-table pure-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
ID
|
||||
</th>
|
||||
<th>
|
||||
Active
|
||||
</th>
|
||||
<th>
|
||||
Name
|
||||
</th>
|
||||
|
||||
<th>
|
||||
Email
|
||||
</th>
|
||||
<th>
|
||||
Roles
|
||||
</th>
|
||||
<th>
|
||||
Created
|
||||
</th>
|
||||
<th>
|
||||
Last Login
|
||||
</th>
|
||||
<th>
|
||||
Action
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ $slug := .Instance.Slug }}
|
||||
{{ $csrf := .CSRF }}
|
||||
{{ range $index, $user := .Info.Users }}
|
||||
<tr {{ if not $user.Status }}style="color:gray"{{ end }}>
|
||||
<td>
|
||||
<code>{{ $user.UID }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ $user.Status }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ $user.Name }}</code>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{{ if $user.Mail }}
|
||||
<a href="mailto:{{ $user.Mail }}">{{ $user.Mail }}</a>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td>
|
||||
{{ range $role, $unuused := $user.Roles }}
|
||||
<code>
|
||||
{{ $role }}
|
||||
</code>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td>
|
||||
<code class="date">{{ $user.Created.Time.Format "2006-01-02T15:04:05Z07:00" }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code class="date">{{ $user.Login.Time.Format "2006-01-02T15:04:05Z07:00" }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<form action="/admin/login" method="POST" target="_blank">
|
||||
<input type="hidden" name="slug" value="{{ $slug }}">
|
||||
<input type="hidden" name="user" value="{{ $user.Name }}">
|
||||
<input type="submit" class="pure-button pure-button-action" value="Login in new window">
|
||||
{{ $csrf }}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pure-u-1-1">
|
||||
<h2 id="wisski">WissKI Data</h2>
|
||||
</div>
|
||||
|
||||
<div class="pure-u-1 pure-u-xl-1-2">
|
||||
<div class="padding">
|
||||
<div class="overflow">
|
||||
<table class="pure-table pure-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2">
|
||||
Pathbuilders
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{{ range $name, $xml := .Info.Pathbuilders }}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ $name }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code class="pathbuilder" data-name="{{ $name }}">{{ $xml }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1 pure-u-xl-1-2">
|
||||
<div class="padding">
|
||||
<div class="overflow">
|
||||
<table class="pure-table pure-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
URI Prefixes
|
||||
|
||||
{{ if .Info.NoPrefixes }}
|
||||
(excluded from resolver)
|
||||
{{ end }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range $index, $prefix := .Info.Prefixes }}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ $prefix }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pure-u-1-1">
|
||||
<h2 id="stats">Statistics</h2>
|
||||
</div>
|
||||
|
||||
<div class="pure-u-1 pure-u-xl-1-2">
|
||||
<div class="padding">
|
||||
<div class="overflow">
|
||||
<table class="pure-table pure-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="5">
|
||||
Bundles
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
Label
|
||||
</th>
|
||||
<th>
|
||||
Machine Name
|
||||
</th>
|
||||
<th>
|
||||
Count
|
||||
</th>
|
||||
<th>
|
||||
LastEdit
|
||||
</th>
|
||||
<th>
|
||||
MainBundle
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Info.Statistics.Bundles.Bundles }}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ .Label }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ .MachineName }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ .Count }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code class="date">{{ .LastEdit.Time.Format "2006-01-02T15:04:05Z07:00" }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ .MainBundle }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="pure-u-1 pure-u-xl-1-2">
|
||||
<div class="padding">
|
||||
<div class="overflow">
|
||||
<table class="pure-table pure-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2">
|
||||
Triplestore
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
URI
|
||||
</th>
|
||||
<th>
|
||||
Count
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Info.Statistics.Triplestore.Graphs }}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ .URI }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ .Count }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pure-u-1-1">
|
||||
<h2 id="ssh">SSH Keys</h2>
|
||||
<table class="pure-table pure-table-bordered padding">
|
||||
<tbody>
|
||||
{{ range .Info.SSHKeys }}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ . }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pure-u-1-1">
|
||||
<h2 id="snapshots">Snapshots</h2>
|
||||
<p>
|
||||
<button class="remote-action pure-button pure-button-action" data-action="snapshot" data-param="{{ .Instance.Slug }}" data-buffer="1000" data-force-reload>Take a snapshot</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pure-u-1">
|
||||
<table class="pure-table pure-table-bordered padding">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Path</th>
|
||||
<th>Created</th>
|
||||
<th>Packed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Info.Snapshots }}
|
||||
<tr>
|
||||
<td>
|
||||
<code class="path">{{ .Path }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code class="date">{{ .Created.Format "2006-01-02T15:04:05Z07:00" }}</code>
|
||||
</td>
|
||||
<td>
|
||||
{{ .Packed }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pure-u-1-1">
|
||||
<h2 id="overview">Dangerous Actions</h2>
|
||||
</div>
|
||||
|
||||
<div class="pure-u-1 pure-u-xl-2-5">
|
||||
<p>
|
||||
Purging this instance completely removes it from the distillery.
|
||||
Backups containing the instance will remain, but it will not be possible to restore it directly.
|
||||
You must enter the slug <code>{{ .Instance.Slug }}</code> to confirm purging.
|
||||
</p>
|
||||
<form class="pure-form">
|
||||
<fieldset>
|
||||
<input type="text" id="purge-confirm-slug" placeholder="{{ .Instance.Slug }}" />
|
||||
<button class="remote-action pure-button pure-button-danger" data-action="purge" data-param="{{ .Instance.Slug }}" data-confirm-param="#purge-confirm-slug" data-buffer="1000" data-force-reload="/admin/">Purge Instance</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{{ template "_form.html" . }}
|
||||
{{ define "form/title" }}Create User{{ end }}
|
||||
{{ define "form/button" }}Create{{ end }}
|
||||
|
||||
109
internal/dis/component/server/admin/html/users.html
Normal file
109
internal/dis/component/server/admin/html/users.html
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
{{ template "_base.html" . }}
|
||||
{{ define "title" }}Users{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
|
||||
<div class="pure-u-1">
|
||||
<div class="padding">
|
||||
<div class="overflow">
|
||||
|
||||
{{ $E := .Error }}
|
||||
{{ if not (eq $E "") }}
|
||||
<div class="pure-form-group">
|
||||
<p class="error-message">
|
||||
{{ $E }}
|
||||
</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<table class="pure-table pure-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Username
|
||||
</th>
|
||||
<th>
|
||||
Enabled
|
||||
</th>
|
||||
<th>
|
||||
Has Password
|
||||
</th>
|
||||
<th>
|
||||
Admin
|
||||
</th>
|
||||
<th>
|
||||
Passcode (TOTP)
|
||||
</th>
|
||||
<th>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ $csrf := .CSRF }}
|
||||
{{ range .Users }}
|
||||
<tr {{ if not .User.IsEnabled }}style="color:gray"{{ end }}>
|
||||
<td>
|
||||
{{ .User.User }}
|
||||
</td>
|
||||
<td>
|
||||
{{ .User.IsEnabled }}
|
||||
</td>
|
||||
<td>
|
||||
{{ .User.HasPassword }}
|
||||
</td>
|
||||
<td>
|
||||
{{ .User.IsAdmin }}
|
||||
</td>
|
||||
<td>
|
||||
{{ .User.IsTOTPEnabled }}
|
||||
</td>
|
||||
<td>
|
||||
<div class="pure-button-group" role="group">
|
||||
<form action="/admin/users/toggleadmin" method="POST" class="pure-form-group">
|
||||
<input type="hidden" name="user" value="{{ .User.User }}">
|
||||
<input type="submit" class="pure-button" value="{{ if .User.IsAdmin }}Remove Admin{{ else }} Make Admin{{ end }}">
|
||||
{{ $csrf }}
|
||||
</form>
|
||||
<form action="/admin/users/password" method="POST" class="pure-form pure-form-group">
|
||||
<input type="hidden" name="user" value="{{ .User.User }}">
|
||||
<input type="password" name="password" autocomplete="new-password">
|
||||
<input type="submit" class="pure-button" value="Update Password">
|
||||
{{ $csrf }}
|
||||
</form>
|
||||
<form action="/admin/users/unsetpassword" method="POST" class="pure-form-group">
|
||||
<input type="hidden" name="user" value="{{ .User.User }}">
|
||||
<input type="submit" class="pure-button" value="Unset Password">
|
||||
{{ $csrf }}
|
||||
</form>
|
||||
<form action="/admin/users/disable" method="POST" class="pure-form-group">
|
||||
<input type="hidden" name="user" value="{{ .User.User }}">
|
||||
<input type="submit" class="pure-button" {{ if (not .User.IsEnabled) }}disabled{{ end }} value="Disable">
|
||||
{{ $csrf }}
|
||||
</form>
|
||||
<form action="/admin/users/disabletotp" method="POST" class="pure-form-group">
|
||||
<input type="hidden" name="user" value="{{ .User.User }}">
|
||||
<input type="submit" class="pure-button" {{ if (not .User.IsTOTPEnabled) }}disabled{{ end }} value="Remove Passcode">
|
||||
{{ $csrf }}
|
||||
</form>
|
||||
<form action="/admin/users/delete" method="POST" class="pure-form-group">
|
||||
<input type="hidden" name="user" value="{{ .User.User }}">
|
||||
<input type="submit" class="pure-button pure-button-danger" value="Delete">
|
||||
{{ $csrf }}
|
||||
</form>
|
||||
<form action="/admin/users/impersonate" method="POST" class="pure-form-group">
|
||||
<input type="hidden" name="user" value="{{ .User.User }}">
|
||||
<input type="submit" class="pure-button" {{ if (not .User.IsEnabled) }}disabled{{ end }} value="Impersonate">
|
||||
{{ $csrf }}
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ end }}
|
||||
107
internal/dis/component/server/admin/index.go
Normal file
107
internal/dis/component/server/admin/index.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/assets"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templates"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/status"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// Status produces a new observation of the distillery, and a new information of all instances
|
||||
// The information on all instances is passed the given quick flag.
|
||||
func (admin *Admin) Status(ctx context.Context, QuickInformation bool) (target status.Distillery, information []status.WissKI, err error) {
|
||||
var group errgroup.Group
|
||||
|
||||
group.Go(func() error {
|
||||
// list all the instances
|
||||
all, err := admin.Dependencies.Instances.All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get all of their info!
|
||||
information = make([]status.WissKI, len(all))
|
||||
for i, instance := range all {
|
||||
{
|
||||
i := i
|
||||
instance := instance
|
||||
|
||||
// store the info for this group!
|
||||
group.Go(func() (err error) {
|
||||
information[i], err = instance.Info().Information(ctx, true)
|
||||
return err
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// gather all the observations
|
||||
flags := component.FetcherFlags{
|
||||
Context: ctx,
|
||||
}
|
||||
for _, o := range admin.Dependencies.Fetchers {
|
||||
o := o
|
||||
group.Go(func() error {
|
||||
return o.Fetch(flags, &target)
|
||||
})
|
||||
}
|
||||
|
||||
// wait for all the fetchers to finish
|
||||
if err := group.Wait(); err != nil {
|
||||
return status.Distillery{}, nil, err
|
||||
}
|
||||
|
||||
// count overall instances
|
||||
for _, i := range information {
|
||||
if i.Running {
|
||||
target.RunningCount++
|
||||
} else {
|
||||
target.StoppedCount++
|
||||
}
|
||||
}
|
||||
target.TotalCount = len(information)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (admin *Admin) Fetch(flags component.FetcherFlags, target *status.Distillery) error {
|
||||
target.Time = time.Now().UTC()
|
||||
target.Config = admin.Config
|
||||
return nil
|
||||
}
|
||||
|
||||
//go:embed "html/index.html"
|
||||
var indexHTML []byte
|
||||
var indexTemplate = templates.Parse[indexContext]("index.html", indexHTML, assets.AssetsAdmin)
|
||||
|
||||
type indexContext struct {
|
||||
templates.BaseContext
|
||||
|
||||
status.Distillery
|
||||
Instances []status.WissKI
|
||||
}
|
||||
|
||||
func (admin *Admin) index(ctx context.Context) http.Handler {
|
||||
tpl := indexTemplate.Prepare(admin.Dependencies.Templating, templates.BaseContextGaps{
|
||||
Crumbs: []component.MenuItem{
|
||||
{Title: "Admin", Path: "/admin/"},
|
||||
},
|
||||
Actions: []component.MenuItem{
|
||||
{Title: "Users", Path: "/admin/users/"},
|
||||
{Title: "Components", Path: "/admin/components/", Priority: component.SmallButton},
|
||||
},
|
||||
})
|
||||
|
||||
return tpl.HTMLHandlerWithGaps(func(r *http.Request, gaps *templates.BaseContextGaps) (idx indexContext, err error) {
|
||||
idx.Distillery, idx.Instances, err = admin.Status(r.Context(), true)
|
||||
return
|
||||
})
|
||||
}
|
||||
68
internal/dis/component/server/admin/instance.go
Normal file
68
internal/dis/component/server/admin/instance.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"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/templates"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/models"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/status"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
//go:embed "html/instance.html"
|
||||
var instanceHTML []byte
|
||||
var instanceTemplate = templates.Parse[instanceContext]("instance.html", instanceHTML, assets.AssetsAdmin)
|
||||
|
||||
type instanceContext struct {
|
||||
templates.BaseContext
|
||||
|
||||
Instance models.Instance
|
||||
Info status.WissKI
|
||||
}
|
||||
|
||||
func (admin *Admin) instance(ctx context.Context) http.Handler {
|
||||
tpl := instanceTemplate.Prepare(admin.Dependencies.Templating, templates.BaseContextGaps{
|
||||
Crumbs: []component.MenuItem{
|
||||
{Title: "Admin", Path: "/admin/"},
|
||||
{Title: "Instance", Path: "*to be replaced*"},
|
||||
},
|
||||
Actions: []component.MenuItem{
|
||||
{Title: "Grants", Path: "*to be replaced*"},
|
||||
{Title: "Ingredients", Path: "*to be replaced*", Priority: component.SmallButton},
|
||||
},
|
||||
})
|
||||
|
||||
return tpl.HTMLHandlerWithGaps(func(r *http.Request, gaps *templates.BaseContextGaps) (ic instanceContext, err error) {
|
||||
slug := httprouter.ParamsFromContext(r.Context()).ByName("slug")
|
||||
|
||||
gaps.Crumbs[1] = component.MenuItem{Title: "Instance", Path: template.URL("/admin/instance/" + slug)}
|
||||
|
||||
gaps.Actions[0] = component.MenuItem{Title: "Grants", Path: template.URL("/admin/grants/" + slug)}
|
||||
gaps.Actions[1] = component.MenuItem{Title: "Ingredients", Path: template.URL("/admin/ingredients/" + slug), Priority: component.SmallButton}
|
||||
|
||||
// find the instance itself!
|
||||
instance, err := admin.Dependencies.Instances.WissKI(r.Context(), slug)
|
||||
if err == instances.ErrWissKINotFound {
|
||||
return ic, httpx.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return ic, err
|
||||
}
|
||||
ic.Instance = instance.Instance
|
||||
|
||||
// get some more info about the wisski
|
||||
ic.Info, err = instance.Info().Information(r.Context(), false)
|
||||
if err != nil {
|
||||
return ic, err
|
||||
}
|
||||
|
||||
return
|
||||
})
|
||||
}
|
||||
153
internal/dis/component/server/admin/socket.go
Normal file
153
internal/dis/component/server/admin/socket.go
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/wisski"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
"github.com/tkw1536/goprogram/status"
|
||||
)
|
||||
|
||||
type InstanceAction struct {
|
||||
NumParams int
|
||||
|
||||
HandleInteractive func(ctx context.Context, info *Admin, instance *wisski.WissKI, out io.Writer, params ...string) error
|
||||
HandleResult func(ctx context.Context, info *Admin, instance *wisski.WissKI, params ...string) (value any, err error)
|
||||
}
|
||||
|
||||
var socketInstanceActions = map[string]InstanceAction{
|
||||
"snapshot": {
|
||||
HandleInteractive: func(ctx context.Context, admin *Admin, instance *wisski.WissKI, out io.Writer, params ...string) error {
|
||||
return admin.Dependencies.Exporter.MakeExport(
|
||||
ctx,
|
||||
out,
|
||||
exporter.ExportTask{
|
||||
Dest: "",
|
||||
Instance: instance,
|
||||
|
||||
StagingOnly: false,
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
"rebuild": {
|
||||
HandleInteractive: func(ctx context.Context, _ *Admin, instance *wisski.WissKI, out io.Writer, params ...string) error {
|
||||
return instance.Barrel().Build(ctx, out, true)
|
||||
},
|
||||
},
|
||||
"update": {
|
||||
HandleInteractive: func(ctx context.Context, _ *Admin, instance *wisski.WissKI, out io.Writer, params ...string) error {
|
||||
return instance.Drush().Update(ctx, out)
|
||||
},
|
||||
},
|
||||
"cron": {
|
||||
HandleInteractive: func(ctx context.Context, _ *Admin, instance *wisski.WissKI, str io.Writer, params ...string) error {
|
||||
return instance.Drush().Cron(ctx, str)
|
||||
},
|
||||
},
|
||||
"start": {
|
||||
HandleInteractive: func(ctx context.Context, _ *Admin, instance *wisski.WissKI, out io.Writer, params ...string) error {
|
||||
return instance.Barrel().Stack().Up(ctx, out)
|
||||
},
|
||||
},
|
||||
"stop": {
|
||||
HandleInteractive: func(ctx context.Context, _ *Admin, instance *wisski.WissKI, out io.Writer, params ...string) error {
|
||||
return instance.Barrel().Stack().Down(ctx, out)
|
||||
},
|
||||
},
|
||||
"purge": {
|
||||
HandleInteractive: func(ctx context.Context, admin *Admin, instance *wisski.WissKI, out io.Writer, params ...string) error {
|
||||
return admin.Dependencies.Purger.Purge(ctx, out, instance.Slug)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func (admin *Admin) serveSocket(conn httpx.WebSocketConnection) {
|
||||
// read the next message to act on
|
||||
message, ok := <-conn.Read()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// perform an action if it exists!
|
||||
if action, ok := socketInstanceActions[string(message.Bytes)]; ok {
|
||||
admin.handleInstanceAction(conn, action)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var instanceParamsTimeout = time.Second
|
||||
|
||||
func (admin *Admin) handleInstanceAction(conn httpx.WebSocketConnection, action InstanceAction) {
|
||||
|
||||
// read the slug
|
||||
slug, ok := <-conn.Read()
|
||||
if !ok {
|
||||
<-conn.WriteText("Error reading slug")
|
||||
return
|
||||
}
|
||||
|
||||
// resolve the instance
|
||||
instance, err := admin.Dependencies.Instances.WissKI(conn.Context(), string(slug.Bytes))
|
||||
if err != nil {
|
||||
<-conn.WriteText("Instance not found")
|
||||
return
|
||||
}
|
||||
|
||||
// read the parameters
|
||||
params := make([]string, action.NumParams)
|
||||
for i := range params {
|
||||
select {
|
||||
case message, ok := <-conn.Read():
|
||||
if !ok {
|
||||
<-conn.WriteText("Insufficient parameters")
|
||||
return
|
||||
}
|
||||
params[i] = string(message.Bytes)
|
||||
case <-time.After(instanceParamsTimeout):
|
||||
<-conn.WriteText("Timed out reading parameters")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// build a stream
|
||||
writer := &status.LineBuffer{
|
||||
Line: func(line string) {
|
||||
<-conn.WriteText(line)
|
||||
},
|
||||
FlushLineOnClose: true,
|
||||
}
|
||||
defer writer.Close()
|
||||
|
||||
// handle the interactive action
|
||||
if action.HandleInteractive != nil {
|
||||
err := action.HandleInteractive(conn.Context(), admin, instance, writer, params...)
|
||||
if err != nil {
|
||||
fmt.Fprintln(writer, err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(writer, "done")
|
||||
}
|
||||
|
||||
// handle the result computation
|
||||
if action.HandleResult != nil {
|
||||
result, err := action.HandleResult(conn.Context(), admin, instance, params...)
|
||||
if err != nil {
|
||||
fmt.Fprintln(writer, "false")
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
fmt.Fprintln(writer, "false")
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(writer, "true")
|
||||
fmt.Fprintln(writer, data)
|
||||
}
|
||||
|
||||
}
|
||||
251
internal/dis/component/server/admin/users.go
Normal file
251
internal/dis/component/server/admin/users.go
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"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/server/assets"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templates"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx/field"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
//go:embed "html/users.html"
|
||||
var usersHTML []byte
|
||||
var usersTemplate = templates.Parse[usersContext]("user.html", usersHTML, assets.AssetsAdmin)
|
||||
|
||||
type usersContext struct {
|
||||
templates.BaseContext
|
||||
|
||||
Error string
|
||||
Users []*auth.AuthUser
|
||||
}
|
||||
|
||||
func (admin *Admin) users(ctx context.Context) http.Handler {
|
||||
tpl := usersTemplate.Prepare(admin.Dependencies.Templating, templates.BaseContextGaps{
|
||||
Crumbs: []component.MenuItem{
|
||||
{Title: "Admin", Path: "/admin/"},
|
||||
{Title: "Users", Path: "/admin/users/"},
|
||||
},
|
||||
Actions: []component.MenuItem{
|
||||
{Title: "Create New", Path: "/admin/users/create/"},
|
||||
},
|
||||
})
|
||||
|
||||
return tpl.HTMLHandler(func(r *http.Request) (uc usersContext, err error) {
|
||||
uc.Error = r.URL.Query().Get("error")
|
||||
uc.Users, err = admin.Dependencies.Auth.Users(r.Context())
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
//go:embed "html/user_create.html"
|
||||
var userCreateHTML []byte
|
||||
var userCreateTemplate = templates.ParseForm("user_create.html", userCreateHTML, assets.AssetsAdmin)
|
||||
|
||||
var (
|
||||
errCreateInvalidUsername = errors.New("invalid username")
|
||||
errCreateInvalidPassword = errors.New("invalid password")
|
||||
)
|
||||
|
||||
type createUserResult struct {
|
||||
User string
|
||||
Passsword string
|
||||
Admin bool
|
||||
}
|
||||
|
||||
func (admin *Admin) createUser(ctx context.Context) http.Handler {
|
||||
tpl := userCreateTemplate.Prepare(admin.Dependencies.Templating, templates.BaseContextGaps{
|
||||
Crumbs: []component.MenuItem{
|
||||
{Title: "Admin", Path: "/admin/"},
|
||||
{Title: "Users", Path: "/admin/users"},
|
||||
{Title: "Create", Path: "/admin/users/create"},
|
||||
},
|
||||
})
|
||||
|
||||
return &httpx.Form[createUserResult]{
|
||||
Fields: []field.Field{
|
||||
{Name: "username", Type: field.Text, Autocomplete: field.Username, Label: "Username"},
|
||||
{Name: "password", Type: field.Password, Autocomplete: field.NewPassword, Label: "Password"},
|
||||
{Name: "admin", Type: field.Checkbox, Label: "Distillery Administrator"},
|
||||
},
|
||||
FieldTemplate: field.PureCSSFieldTemplate,
|
||||
|
||||
RenderTemplate: tpl.Template(),
|
||||
RenderTemplateContext: templates.FormTemplateContext(tpl),
|
||||
|
||||
Validate: func(r *http.Request, values map[string]string) (cu createUserResult, err error) {
|
||||
cu.User, cu.Passsword, cu.Admin = values["username"], values["password"], values["admin"] == field.CheckboxChecked
|
||||
|
||||
if cu.User == "" {
|
||||
return cu, errCreateInvalidUsername
|
||||
}
|
||||
if cu.Passsword == "" {
|
||||
return cu, errCreateInvalidPassword
|
||||
}
|
||||
|
||||
// check the password policy
|
||||
err = admin.Dependencies.Auth.CheckPasswordPolicy(cu.Passsword, cu.User)
|
||||
if err != nil {
|
||||
return cu, err
|
||||
}
|
||||
|
||||
return cu, nil
|
||||
},
|
||||
|
||||
RenderSuccess: func(cu createUserResult, values map[string]string, w http.ResponseWriter, r *http.Request) error {
|
||||
// create the user
|
||||
user, err := admin.Dependencies.Auth.CreateUser(r.Context(), cu.User)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// disable the user and setup the admin flag
|
||||
user.SetAdmin(cu.Admin)
|
||||
if err := user.Save(r.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// set the password!
|
||||
err = user.SetPassword(r.Context(), []byte(cu.Passsword))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// everything went fine, redirect the user back to the user page!
|
||||
http.Redirect(w, r, "/admin/users/", http.StatusSeeOther)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var errNotCurrentUser = httpx.Response{
|
||||
Body: []byte("attempt to modify current user"),
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
|
||||
func (admin *Admin) useraction(ctx context.Context, name string, action func(r *http.Request, user *auth.AuthUser) error) http.Handler {
|
||||
logger := zerolog.Ctx(ctx)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
logger.Err(err).Str("action", name).Msg("failed to parse form")
|
||||
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.PostFormValue("user")
|
||||
user, err := admin.Dependencies.Auth.User(r.Context(), username)
|
||||
if err != nil {
|
||||
logger.Err(err).Str("action", name).Msg("failed to get user")
|
||||
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
me, err := admin.Dependencies.Auth.UserOf(r)
|
||||
if err != nil {
|
||||
logger.Err(err).Str("action", name).Msg("failed to get current user")
|
||||
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// don't allow the current user
|
||||
if me.User.User == user.User.User {
|
||||
errNotCurrentUser.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := action(r, user); err != nil {
|
||||
logger.Err(err).Str("action", name).Msg("failed to act on user")
|
||||
http.Redirect(w, r, "/admin/users/?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/admin/users/", http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
|
||||
func (admin *Admin) usersDeleteHandler(ctx context.Context) http.Handler {
|
||||
return admin.useraction(ctx, "delete user", func(r *http.Request, user *auth.AuthUser) error {
|
||||
return user.Delete(r.Context())
|
||||
})
|
||||
}
|
||||
|
||||
func (admin *Admin) usersDisableHandler(ctx context.Context) http.Handler {
|
||||
return admin.useraction(ctx, "disable user", func(r *http.Request, user *auth.AuthUser) error {
|
||||
return user.UnsetPassword(r.Context())
|
||||
})
|
||||
}
|
||||
|
||||
func (admin *Admin) usersDisableTOTPHandler(ctx context.Context) http.Handler {
|
||||
return admin.useraction(ctx, "disable user totp", func(r *http.Request, user *auth.AuthUser) error {
|
||||
return user.DisableTOTP(r.Context())
|
||||
})
|
||||
}
|
||||
|
||||
func (admin *Admin) usersToggleAdmin(ctx context.Context) http.Handler {
|
||||
return admin.useraction(ctx, "toggle admin", func(r *http.Request, user *auth.AuthUser) error {
|
||||
if user.IsAdmin() {
|
||||
return user.MakeRegular(r.Context())
|
||||
}
|
||||
return user.MakeAdmin(r.Context())
|
||||
})
|
||||
}
|
||||
|
||||
func (admin *Admin) usersPasswordHandler(ctx context.Context) http.Handler {
|
||||
return admin.useraction(ctx, "set password", func(r *http.Request, user *auth.AuthUser) error {
|
||||
password := r.PostFormValue("password")
|
||||
if password == "" {
|
||||
return httpx.ErrBadRequest
|
||||
}
|
||||
// check the password policy
|
||||
err := user.CheckPasswordPolicy(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return user.SetPassword(r.Context(), []byte(password))
|
||||
})
|
||||
}
|
||||
|
||||
func (admin *Admin) usersUnsetPasswordHandler(ctx context.Context) http.Handler {
|
||||
return admin.useraction(ctx, "unset password", func(r *http.Request, user *auth.AuthUser) error {
|
||||
user.PasswordHash = nil
|
||||
return user.Save(r.Context())
|
||||
})
|
||||
}
|
||||
|
||||
func (admin *Admin) usersImpersonateHandler(ctx context.Context) http.Handler {
|
||||
logger := zerolog.Ctx(ctx)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
logger.Err(err).Str("action", "impersonate").Msg("failed to parse form")
|
||||
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.PostFormValue("user")
|
||||
user, err := admin.Dependencies.Auth.User(r.Context(), username)
|
||||
if err != nil {
|
||||
logger.Err(err).Str("action", "impersonate").Msg("failed to get user")
|
||||
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// login the user into the session of the provided user
|
||||
if err := admin.Dependencies.Auth.Login(w, r, user); err != nil {
|
||||
logger.Err(err).Str("action", "impersonate").Msg("failed to login user")
|
||||
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// and go there
|
||||
http.Redirect(w, r, "/user/", http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
48
internal/dis/component/server/assets/assets.go
Normal file
48
internal/dis/component/server/assets/assets.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package assets
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
)
|
||||
|
||||
// Assets represents a group of assets to be included inside a template.
|
||||
//
|
||||
// Assets are generated using the 'build.mjs' script.
|
||||
// The script is called using 'go:generate', which stores variables in the form of 'Assets{{Name}}' inside this package.
|
||||
//
|
||||
// The build script roughly works as follows:
|
||||
// - Delete any previously generated distribution directory.
|
||||
// - Bundle the entrypoint sources under 'src/entry/{{Name}}/index.{ts,css}' together with the base './src/base/index.{ts,css}'
|
||||
// - Store the output inside the 'dist' directory
|
||||
// - Generate new constants of the form Assets{{Name}}
|
||||
//
|
||||
// Each asset group should be registered as a parameter to the 'go:generate' line.
|
||||
type Assets struct {
|
||||
Scripts string // <script> tags inserted by the asset
|
||||
Styles string // <link> tags inserted by the asset
|
||||
}
|
||||
|
||||
//go:generate node build.mjs Default User Admin
|
||||
|
||||
// MustParse parses a new template from the given source
|
||||
// and calls [RegisterAssoc] on it.
|
||||
func (assets *Assets) MustParse(t *template.Template, value string) *template.Template {
|
||||
t = template.Must(t.Parse(value))
|
||||
assets.RegisterAssoc(t)
|
||||
return t
|
||||
}
|
||||
|
||||
// MustParseShared is like [MustParse], but creates a new SharedTemplate instead
|
||||
func (assets *Assets) MustParseShared(name string, value string) *template.Template {
|
||||
return assets.MustParse(NewSharedTemplate(name), value)
|
||||
}
|
||||
|
||||
// RegisterAssoc registers two new associated templates with t.
|
||||
//
|
||||
// The template "scripts" will render all script tags required.
|
||||
// The template "styles" will render all style tags required.
|
||||
//
|
||||
// If either template already exists, it will be overwritten.
|
||||
func (assets *Assets) RegisterAssoc(t *template.Template) {
|
||||
t.New("scripts").Parse(assets.Scripts)
|
||||
t.New("styles").Parse(assets.Styles)
|
||||
}
|
||||
2837
internal/dis/component/server/assets/assets_disclaimer.txt
Normal file
2837
internal/dis/component/server/assets/assets_disclaimer.txt
Normal file
File diff suppressed because it is too large
Load diff
29
internal/dis/component/server/assets/assets_dist.go
Normal file
29
internal/dis/component/server/assets/assets_dist.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package assets
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// This file was automatically generated. Do not edit.
|
||||
|
||||
//go:embed "assets_disclaimer.txt"
|
||||
var Disclaimer string
|
||||
|
||||
// Public holds the path to the public route
|
||||
const Public = "/this-is-fine/"
|
||||
|
||||
// AssetsDefault contains assets for the 'Default' entrypoint.
|
||||
var AssetsDefault = Assets{
|
||||
Scripts: `<script type="module" src="/this-is-fine/Default.38d394c2.js"></script><script src="/this-is-fine/Default.38d394c2.js" nomodule="" defer></script><script type="module" src="/this-is-fine/Default.38d394c2.js"></script><script src="/this-is-fine/Default.38d394c2.js" nomodule="" defer></script>`,
|
||||
Styles: `<link rel="stylesheet" href="/this-is-fine/Default.db26a303.css"><link rel="stylesheet" href="/this-is-fine/Default.f9675eae.css">`,
|
||||
}
|
||||
|
||||
// AssetsUser contains assets for the 'User' entrypoint.
|
||||
var AssetsUser = Assets{
|
||||
Scripts: `<script type="module" src="/this-is-fine/Default.38d394c2.js"></script><script src="/this-is-fine/Default.38d394c2.js" nomodule="" defer></script><script type="module" src="/this-is-fine/User.e0367d79.js"></script><script src="/this-is-fine/User.b2f9a57c.js" nomodule="" defer></script>`,
|
||||
Styles: `<link rel="stylesheet" href="/this-is-fine/Default.db26a303.css"><link rel="stylesheet" href="/this-is-fine/User.68febbf8.css"><link rel="stylesheet" href="/this-is-fine/User.840de3b4.css">`,
|
||||
}
|
||||
|
||||
// AssetsAdmin contains assets for the 'Admin' entrypoint.
|
||||
var AssetsAdmin = Assets{
|
||||
Scripts: `<script nomodule="" defer src="/this-is-fine/User.b2f9a57c.js"></script><script type="module" src="/this-is-fine/User.e0367d79.js"></script><script type="module" src="/this-is-fine/Default.38d394c2.js"></script><script src="/this-is-fine/Default.38d394c2.js" nomodule="" defer></script><script type="module" src="/this-is-fine/Admin.1a380f6f.js"></script><script src="/this-is-fine/Admin.cb58d290.js" nomodule="" defer></script>`,
|
||||
Styles: `<link rel="stylesheet" href="/this-is-fine/Default.db26a303.css"><link rel="stylesheet" href="/this-is-fine/Admin.6d59e220.css"><link rel="stylesheet" href="/this-is-fine/User.840de3b4.css"><link rel="stylesheet" href="/this-is-fine/User.68febbf8.css"><link rel="stylesheet" href="/this-is-fine/Admin.6d2ae968.css">`,
|
||||
}
|
||||
164
internal/dis/component/server/assets/build.mjs
Normal file
164
internal/dis/component/server/assets/build.mjs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { Parcel } from "@parcel/core"
|
||||
import { mkdir, rm, writeFile, readFile, unlink, rmdir, } from "fs/promises"
|
||||
import { join } from "path"
|
||||
import { parse as parseHTML } from 'node-html-parser';
|
||||
import { spawnSync } from 'child_process'
|
||||
|
||||
//
|
||||
// PARAMETERS
|
||||
//
|
||||
|
||||
const ENTRYPOINTS = process.argv.slice(2)
|
||||
const ENTRY_DIR = join('.', '.entry-cache') // directory to place entries into
|
||||
const DIST_DIR = join('.', 'dist')
|
||||
const PUBLIC_DIR = '/this-is-fine/'
|
||||
|
||||
const DEST_PACKAGE = process.env.GOPACKAGE ?? 'static'
|
||||
const DEST_DISCLAIMER = (() => {
|
||||
const source = (process.env.GOFILE ?? 'assets.go')
|
||||
const base = source.substring(0, source.length - '.go'.length)
|
||||
return base + '_disclaimer.txt'
|
||||
})()
|
||||
const DEST_FILE = (() => {
|
||||
const source = (process.env.GOFILE ?? 'assets.go')
|
||||
const base = source.substring(0, source.length - '.go'.length)
|
||||
return base + '_dist.go'
|
||||
})()
|
||||
|
||||
//
|
||||
// PREPARE DIRECTORIES
|
||||
//
|
||||
|
||||
process.stdout.write('Preparing directories ...')
|
||||
await Promise.all([
|
||||
mkdir(ENTRY_DIR, { recursive: true }),
|
||||
rm(DIST_DIR, { recursive: true, force: true })
|
||||
])
|
||||
console.log(' Done.')
|
||||
|
||||
|
||||
//
|
||||
// Write the disclaimer
|
||||
//
|
||||
|
||||
process.stdout.write('Generating legal disclaimer ...')
|
||||
|
||||
const disclaimer = await new Promise((r, e) => {
|
||||
var child = spawnSync("yarn", ["licenses", "generate-disclaimer"], { encoding : 'utf8' });
|
||||
if (child.error) {
|
||||
e(child.stderr)
|
||||
return
|
||||
}
|
||||
|
||||
r(child.stdout)
|
||||
});
|
||||
|
||||
console.log(' Done.')
|
||||
|
||||
|
||||
process.stdout.write(`Writing ${DEST_DISCLAIMER} ...`)
|
||||
await writeFile(DEST_DISCLAIMER, disclaimer)
|
||||
console.log(' Done.')
|
||||
|
||||
|
||||
//
|
||||
// WRITE ENTRY POINTS
|
||||
//
|
||||
|
||||
process.stdout.write('Collecting entry points ')
|
||||
const entries = await Promise.all(ENTRYPOINTS.map(async (name) => {
|
||||
const entry = {
|
||||
'name': name,
|
||||
'bundleName': name + '.html',
|
||||
'src': join(ENTRY_DIR, name + '.html'),
|
||||
}
|
||||
|
||||
const content = `
|
||||
<script type='module' src='../src/base/index.ts'></script>
|
||||
<script type='module' src='../src/entry/${name}/index.ts'></script>
|
||||
<link rel='stylesheet' href='../src/entry/${name}/index.css'>
|
||||
`;
|
||||
await writeFile(entry.src, content)
|
||||
|
||||
process.stdout.write('.')
|
||||
return entry;
|
||||
}))
|
||||
console.log(' Done.')
|
||||
|
||||
//
|
||||
// BUNDLEING
|
||||
//
|
||||
|
||||
process.stdout.write('Bundleing assets ...')
|
||||
const bundler = new Parcel({
|
||||
entries: entries.map(e => e.src),
|
||||
defaultConfig: '@parcel/config-default',
|
||||
shouldDisableCache: true,
|
||||
shouldContentHash: true,
|
||||
defaultTargetOptions: {
|
||||
shouldOptimize: true,
|
||||
shouldScopeHoist: true,
|
||||
sourceMaps: false,
|
||||
distDir: DIST_DIR,
|
||||
publicUrl: PUBLIC_DIR,
|
||||
engines: {
|
||||
browsers: "defaults",
|
||||
}
|
||||
}
|
||||
});
|
||||
const { bundleGraph } = await bundler.run()
|
||||
console.log(' Done.')
|
||||
|
||||
//
|
||||
// FIND ASSETS IN OUTPUT
|
||||
//
|
||||
|
||||
process.stdout.write('Find Assets in Output ')
|
||||
const bundles = bundleGraph.getBundles()
|
||||
const assets = await Promise.all(entries.map(async (entry) => {
|
||||
const mainBundle = bundles.find(b => b.name === entry.bundleName)
|
||||
if (mainBundle === undefined) throw new Error('Unable to find bundle for ' + entry.name)
|
||||
|
||||
// read, then delete the generated output file
|
||||
const { filePath } = mainBundle
|
||||
const html = parseHTML(await readFile(filePath))
|
||||
await unlink(filePath)
|
||||
|
||||
const scripts = html.querySelectorAll('script').map(script => script.outerHTML).join('')
|
||||
const links = html.querySelectorAll('link').map(link => link.outerHTML).join('')
|
||||
|
||||
process.stdout.write('.')
|
||||
return { ...entry, scripts, links }
|
||||
}))
|
||||
console.log(' Done.')
|
||||
|
||||
//
|
||||
// GENERATE GO
|
||||
//
|
||||
|
||||
process.stdout.write(`Writing ${DEST_FILE} ...`)
|
||||
const goAssets = assets.map(({ name, scripts, links }) => {
|
||||
return `
|
||||
// Assets${name} contains assets for the '${name}' entrypoint.
|
||||
var Assets${name} = Assets{
|
||||
\tScripts: \`${scripts}\`,
|
||||
\tStyles: \`${links}\`,\t
|
||||
}`.trim()
|
||||
}).join('\n\n')
|
||||
const goSource = `package ${DEST_PACKAGE}
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// This file was automatically generated. Do not edit.
|
||||
|
||||
//go:embed ${JSON.stringify(DEST_DISCLAIMER)}
|
||||
var Disclaimer string
|
||||
|
||||
// Public holds the path to the public route
|
||||
const Public = ${JSON.stringify(PUBLIC_DIR)}
|
||||
|
||||
${goAssets}
|
||||
`;
|
||||
|
||||
await writeFile(DEST_FILE, goSource)
|
||||
console.log(' Done.')
|
||||
1
internal/dis/component/server/assets/dist/Admin.1a380f6f.js
vendored
Normal file
1
internal/dis/component/server/assets/dist/Admin.1a380f6f.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
internal/dis/component/server/assets/dist/Admin.6d2ae968.css
vendored
Normal file
1
internal/dis/component/server/assets/dist/Admin.6d2ae968.css
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.wisski{padding:1em}.wisski h3{padding:0}.wisski a.pure-button{float:right;position:relative;bottom:1em}.wisski.running{background-color:#9ada07}.wisski.stopped{background-color:#ff7a7a}
|
||||
1
internal/dis/component/server/assets/dist/Admin.6d59e220.css
vendored
Normal file
1
internal/dis/component/server/assets/dist/Admin.6d59e220.css
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.modal-terminal{width:66vw;height:66vh;background-color:#fff;background-clip:padding-box;-webkit-background-clip:padding-box;z-index:1000;border:17vh solid #000c;border-width:17vh 17vw;margin:-17vh -17vw;position:fixed;top:17vh;left:17vw;overflow:auto}.modal-terminal button{z-index:1001;position:fixed;top:17vh;right:17vw}.modal-terminal pre,.modal-terminal button{margin:5px}
|
||||
1
internal/dis/component/server/assets/dist/Admin.cb58d290.js
vendored
Normal file
1
internal/dis/component/server/assets/dist/Admin.cb58d290.js
vendored
Normal file
File diff suppressed because one or more lines are too long
0
internal/dis/component/server/assets/dist/Default.38d394c2.js
vendored
Normal file
0
internal/dis/component/server/assets/dist/Default.38d394c2.js
vendored
Normal file
1
internal/dis/component/server/assets/dist/Default.db26a303.css
vendored
Normal file
1
internal/dis/component/server/assets/dist/Default.db26a303.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
internal/dis/component/server/assets/dist/Default.f9675eae.css
vendored
Normal file
1
internal/dis/component/server/assets/dist/Default.f9675eae.css
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
a.wisskilink{color:#00f!important}
|
||||
1
internal/dis/component/server/assets/dist/User.68febbf8.css
vendored
Normal file
1
internal/dis/component/server/assets/dist/User.68febbf8.css
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.copy{-webkit-user-select:all;user-select:all}
|
||||
1
internal/dis/component/server/assets/dist/User.840de3b4.css
vendored
Normal file
1
internal/dis/component/server/assets/dist/User.840de3b4.css
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
textarea#key{width:50%;height:10em;resize:both}
|
||||
1
internal/dis/component/server/assets/dist/User.b2f9a57c.js
vendored
Normal file
1
internal/dis/component/server/assets/dist/User.b2f9a57c.js
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
!function(){var e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{},r={},n={},o=e.parcelRequireafa4;null==o&&((o=function(e){if(e in r)return r[e].exports;if(e in n){var o=n[e];delete n[e];var t={id:e,exports:{}};return r[e]=t,o.call(t.exports,t,t.exports),t.exports}var i=new Error("Cannot find module '"+e+"'");throw i.code="MODULE_NOT_FOUND",i}).register=function(e,r){n[e]=r},e.parcelRequireafa4=o),o.register("kEAtK",(function(e,r){o("15EWx")})),o.register("15EWx",(function(e,r){document.querySelectorAll(".copy").forEach((e=>{e.addEventListener("click",(()=>{navigator.clipboard&&navigator.clipboard.writeText(e.innerText)}))}))})),o("kEAtK")}();
|
||||
1
internal/dis/component/server/assets/dist/User.e0367d79.js
vendored
Normal file
1
internal/dis/component/server/assets/dist/User.e0367d79.js
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
var e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{},r={},n={},o=e.parcelRequireafa4;null==o&&((o=function(e){if(e in r)return r[e].exports;if(e in n){var o=n[e];delete n[e];var i={id:e,exports:{}};return r[e]=i,o.call(i.exports,i,i.exports),i.exports}var t=new Error("Cannot find module '"+e+"'");throw t.code="MODULE_NOT_FOUND",t}).register=function(e,r){n[e]=r},e.parcelRequireafa4=o),o.register("gkpdw",(function(e,r){o("hZNgY")})),o.register("hZNgY",(function(e,r){document.querySelectorAll(".copy").forEach((e=>{e.addEventListener("click",(()=>{navigator.clipboard&&navigator.clipboard.writeText(e.innerText)}))}))})),o("gkpdw");
|
||||
13
internal/dis/component/server/assets/package.json
Normal file
13
internal/dis/component/server/assets/package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "wisski-distillery-frontend",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.5",
|
||||
"latex.css": "^1.8.0",
|
||||
"node-html-parser": "^6.1.1",
|
||||
"parcel": "^2.7.0",
|
||||
"purecss": "^2.1.0"
|
||||
}
|
||||
}
|
||||
123
internal/dis/component/server/assets/src/base/index.css
Normal file
123
internal/dis/component/server/assets/src/base/index.css
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
body {
|
||||
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
}
|
||||
|
||||
header,
|
||||
main,
|
||||
footer {
|
||||
margin: 2em;
|
||||
}
|
||||
|
||||
nav.pure-menu, nav.breadcrumbs {
|
||||
padding-top: 1em;
|
||||
padding-bottom: 1em;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
nav.breadcrumbs {
|
||||
padding-left: 1em;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
nav.breadcrumbs a:not(:last-child)::after {
|
||||
cursor: default;
|
||||
content: " > ";
|
||||
color: black;
|
||||
}
|
||||
nav.breadcrumbs a {
|
||||
text-decoration: none;
|
||||
color: blue !important;
|
||||
}
|
||||
nav.breadcrumbs a.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
footer {
|
||||
font-size: small;
|
||||
border-top: 1px solid black;
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: blue !important;
|
||||
}
|
||||
|
||||
time {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.padding {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.overflow {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.overflow table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.overflow table td,
|
||||
.overflow table th {
|
||||
padding: .5em .5em;
|
||||
}
|
||||
|
||||
.overflow table td:not(:last-child),
|
||||
.overflow table th:not(:last-child) {
|
||||
width: 1px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overflow table td:last-child,
|
||||
.overflow table th:last-child {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hspace {
|
||||
display: block;
|
||||
height: 1em;
|
||||
}
|
||||
.pure-form-group {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.pure-button-action {
|
||||
background-color: rgb(66, 184, 221) !important;
|
||||
}
|
||||
|
||||
.pure-button-success {
|
||||
background-color: rgb(28, 184, 65) !important;
|
||||
}
|
||||
|
||||
.pure-button-danger {
|
||||
background: rgb(202, 60, 60) !important;
|
||||
}
|
||||
|
||||
.pure-button-warning {
|
||||
background: rgb(223, 117, 20) !important;
|
||||
}
|
||||
|
||||
.pure-button-xsmall {
|
||||
font-size: 70%;
|
||||
}
|
||||
|
||||
.pure-button-small {
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.pure-button-large {
|
||||
font-size: 110%;
|
||||
}
|
||||
|
||||
.pure-button-xlarge {
|
||||
font-size: 125%;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: pink;
|
||||
border: 1px solid red;
|
||||
padding: 2px;
|
||||
color: red;
|
||||
}
|
||||
4
internal/dis/component/server/assets/src/base/index.ts
Normal file
4
internal/dis/component/server/assets/src/base/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import "purecss/build/pure.css"
|
||||
import "purecss/build/grids-responsive.css"
|
||||
|
||||
import "./index.css"
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
.wisski {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.wisski h3 {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wisski a.pure-button {
|
||||
float: right;
|
||||
position: relative;
|
||||
bottom: 1em;
|
||||
}
|
||||
|
||||
.wisski.running {
|
||||
background-color: #9ADA07;
|
||||
}
|
||||
|
||||
.wisski.stopped {
|
||||
background-color: #ff7a7a;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import "~/src/lib/remote"
|
||||
import "~/src/lib/highlight"
|
||||
|
||||
// include the user styles!
|
||||
import "../User/index.ts"
|
||||
import "../User/index.css"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
a.wisskilink {
|
||||
color: blue !important;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/* textarea on the /user/ssh/add form */
|
||||
textarea#key {
|
||||
width: 50%;
|
||||
height: 10em;
|
||||
resize: both;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
import "~/src/lib/copy"
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
.header-link {
|
||||
position: relative;
|
||||
left: 0.5em;
|
||||
opacity: 0;
|
||||
font-size: 0.8em;
|
||||
|
||||
transition: opacity 0.2s ease-in-out 0.1s;
|
||||
-webkit-transition: opacity 0.2s ease-in-out 0.1s;
|
||||
-moz-transition: opacity 0.2s ease-in-out 0.1s;
|
||||
-ms-transition: opacity 0.2s ease-in-out 0.1s;
|
||||
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h2:hover .header-link,
|
||||
h3:hover .header-link,
|
||||
h4:hover .header-link,
|
||||
h5:hover .header-link,
|
||||
h6:hover .header-link {
|
||||
color: black !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import "./index.css"
|
||||
|
||||
/** Adapted from http://blog.parkermoore.de/2014/08/01/header-anchor-links-in-vanilla-javascript-for-github-pages-and-jekyll/ */
|
||||
const anchorForId = (id: string) => {
|
||||
const anchor = document.createElement("a")
|
||||
anchor.className = "header-link"
|
||||
anchor.href = "#" + id
|
||||
anchor.innerHTML = "#"
|
||||
return anchor
|
||||
}
|
||||
|
||||
const linkifyAnchors = (level: number) => {
|
||||
const headers = document.getElementsByTagName("h" + level);
|
||||
Array.from(headers).forEach((header) => {
|
||||
if (typeof header.id === "undefined" || header.id === "") return
|
||||
header.appendChild(anchorForId(header.id))
|
||||
})
|
||||
}
|
||||
|
||||
// linkify all the anchors from 1 ... 6
|
||||
(new Array(6)).fill(0).forEach((_, i) => linkifyAnchors(i + 1))
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.copy {
|
||||
user-select: all;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import "./index.css"
|
||||
|
||||
document.querySelectorAll('.copy').forEach((elem: Element) => {
|
||||
elem.addEventListener('click', () => {
|
||||
if (!navigator.clipboard) return;
|
||||
navigator.clipboard.writeText((elem as HTMLElement).innerText);
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import dayjs from "dayjs"
|
||||
const types: Record<string, (element: HTMLElement) => HTMLElement | string> = {
|
||||
"date": (element) => {
|
||||
const value = dayjs(element.innerText);
|
||||
const text = value.format('YYYY-MM-DD HH:mm:ss ([UTC]Z)')
|
||||
|
||||
// if the date is the zero date, then it is assumed to be invalid
|
||||
if (value.unix() === 0) {
|
||||
const code = document.createElement('code')
|
||||
code.style.color = 'gray'
|
||||
code.append(text)
|
||||
return code
|
||||
}
|
||||
return text
|
||||
},
|
||||
"path": (element) => {
|
||||
const text = element.innerText.split("/");
|
||||
return text[text.length - 1];
|
||||
},
|
||||
"pathbuilder": (element) => {
|
||||
// create a link and get the blob
|
||||
const filename = (element.getAttribute('data-name') ?? 'pathbuilder') + ".xml"
|
||||
const [link, blob] = make_download_link(filename, element.innerText, "application/xml")
|
||||
|
||||
link.className = "pure-button"
|
||||
const title = filename + ' (' + blob.size + ' Bytes)';
|
||||
link.append(title)
|
||||
return link
|
||||
}
|
||||
}
|
||||
|
||||
const make_download_link = (filename: string, content: string, type: string): [HTMLAnchorElement, Blob] => {
|
||||
const blob = new Blob(
|
||||
[content],
|
||||
{
|
||||
type: type ?? "text/plain"
|
||||
}
|
||||
);
|
||||
|
||||
const link = document.createElement("a")
|
||||
link.target = "_blank"
|
||||
link.download = filename
|
||||
link.href = URL.createObjectURL(blob)
|
||||
|
||||
return [link, blob]
|
||||
}
|
||||
|
||||
Object.keys(types).forEach(key => {
|
||||
const f = types[key];
|
||||
const elements = document.querySelectorAll("code." + key) as NodeListOf<HTMLElement>
|
||||
elements.forEach(element => {
|
||||
const newElement = f(element)
|
||||
if (typeof newElement === 'string') {
|
||||
element.innerHTML = ""
|
||||
element.appendChild(document.createTextNode(newElement))
|
||||
return
|
||||
}
|
||||
|
||||
element.parentNode!.replaceChild(newElement, element)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
.modal-terminal {
|
||||
width: 66vw;
|
||||
height: 66vh;
|
||||
|
||||
position: fixed;
|
||||
left: 17vw;
|
||||
top: 17vh;
|
||||
|
||||
background-color: white;
|
||||
|
||||
background-clip: padding-box;
|
||||
-webkit-background-clip: padding-box;
|
||||
|
||||
border-left: 17vw solid rgba(0, 0, 0, 0.8);
|
||||
border-right: 17vw solid rgba(0, 0, 0, 0.8);
|
||||
margin-left: -17vw;
|
||||
margin-right: -17vw;
|
||||
|
||||
border-top: 17vh solid rgba(0, 0, 0, 0.8);
|
||||
border-bottom: 17vh solid rgba(0, 0, 0, 0.8);
|
||||
margin-top: -17vh;
|
||||
margin-bottom: -17vh;
|
||||
|
||||
overflow: auto;
|
||||
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-terminal button {
|
||||
position: fixed;
|
||||
top: 17vh;
|
||||
right: 17vw;
|
||||
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.modal-terminal pre,
|
||||
.modal-terminal button
|
||||
{
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
208
internal/dis/component/server/assets/src/lib/remote/index.ts
Normal file
208
internal/dis/component/server/assets/src/lib/remote/index.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import "./index.css"
|
||||
import connectSocket from './socket';
|
||||
|
||||
type Println = ((line: string, flush?: boolean) => void) & {
|
||||
paintedFrames: number;
|
||||
missedFrames: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* makeTextBuffer returns a println() function that efficiently writes text into target, and keeps at most size elements in the traceback.
|
||||
* scrollContainer is used to scroll on every painted update.
|
||||
*/
|
||||
function makeTextBuffer(target: HTMLElement, scrollContainer: HTMLElement, size: number): Println {
|
||||
let lastAnimationFrame: number | null = null; // last scheduled animation frame
|
||||
|
||||
const buffer: Array<string> = []; // the internal buffer of lines
|
||||
const paint = () => {
|
||||
println.paintedFrames++
|
||||
target.innerText = buffer.join("\n")
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight
|
||||
lastAnimationFrame = null
|
||||
}
|
||||
|
||||
const println = (line: string, flush?: boolean) => {
|
||||
// add the line
|
||||
buffer.push(line)
|
||||
if (size !== 0 && buffer.length > size) {
|
||||
buffer.splice(0, buffer.length - size)
|
||||
}
|
||||
|
||||
// and update the browser in the next animation frame
|
||||
if (lastAnimationFrame !== null) {
|
||||
println.missedFrames++
|
||||
window.cancelAnimationFrame(lastAnimationFrame)
|
||||
}
|
||||
|
||||
// force a repaint!
|
||||
if(flush) return paint();
|
||||
|
||||
// schedule an animation frame
|
||||
lastAnimationFrame = window.requestAnimationFrame(paint);
|
||||
}
|
||||
println.paintedFrames = 0;
|
||||
println.missedFrames = 0;
|
||||
|
||||
return println;
|
||||
}
|
||||
|
||||
const remote_action = document.getElementsByClassName('remote-action')
|
||||
Array.from(remote_action).forEach((element) => {
|
||||
const action = element.getAttribute('data-action') as string;
|
||||
const reload = element.getAttribute('data-force-reload');
|
||||
const param = element.getAttribute('data-param') as string | undefined;
|
||||
|
||||
const confirmElementName = element.getAttribute('data-confirm-param');
|
||||
const confirmElement = (confirmElementName ? document.querySelector(confirmElementName) : null) as HTMLInputElement | null;
|
||||
|
||||
const bufferSize = (function () {
|
||||
const number = parseInt(element.getAttribute('data-buffer') ?? "", 10) ?? 0;
|
||||
return (isFinite(number) && number > 0) ? number : 0;
|
||||
})()
|
||||
|
||||
const validate = function() {
|
||||
if (!confirmElement) return true
|
||||
return confirmElement.value === param;
|
||||
}
|
||||
|
||||
if (confirmElement) {
|
||||
const runValidation = () => {
|
||||
if (validate()) {
|
||||
element.removeAttribute('disabled')
|
||||
} else {
|
||||
element.setAttribute('disabled', 'disabled')
|
||||
}
|
||||
}
|
||||
confirmElement.addEventListener('change', runValidation)
|
||||
runValidation()
|
||||
}
|
||||
|
||||
element.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
// do nothing if the validation fails
|
||||
if (!validate()) return;
|
||||
|
||||
// create a modal dialog and append it to the body
|
||||
const modal = document.createElement("div")
|
||||
modal.className = "modal-terminal"
|
||||
document.body.append(modal)
|
||||
|
||||
// create a <pre> to write stuff into
|
||||
const target = document.createElement("pre")
|
||||
const println = makeTextBuffer(target, modal, bufferSize)
|
||||
modal.append(target)
|
||||
|
||||
|
||||
// create a button to eventually close everything
|
||||
const button = document.createElement("button")
|
||||
button.className = "pure-button pure-button-success"
|
||||
button.append(typeof reload === 'string' ? "Close & Reload" : "Close")
|
||||
button.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (typeof reload === 'string') {
|
||||
button.setAttribute('disabled', 'disabled')
|
||||
target.innerHTML = 'Reloading page ...'
|
||||
if (reload === '') {
|
||||
location.reload()
|
||||
} else {
|
||||
location.href = reload
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
modal.parentNode?.removeChild(modal);
|
||||
})
|
||||
|
||||
const onbeforeunload = window.onbeforeunload;
|
||||
window.onbeforeunload = () => "A remote session is in progress. Are you sure you want to leave?";
|
||||
|
||||
// when closing, add a button to the modal!
|
||||
let didClose = false
|
||||
const close = function () {
|
||||
if (didClose) return
|
||||
didClose = true
|
||||
|
||||
window.onbeforeunload = onbeforeunload;
|
||||
modal.append(button)
|
||||
// DEBUG: print terminal stats!
|
||||
// const quota = (println.paintedFrames / (println.missedFrames + println.paintedFrames)) * 100
|
||||
// println(`Terminal: painted=${println.paintedFrames} missed=${println.missedFrames} (${quota}%)`, true)
|
||||
}
|
||||
|
||||
println("Connecting ...", true)
|
||||
|
||||
// connect to the socket and send the action
|
||||
connectSocket((socket) => {
|
||||
println("Connected", true)
|
||||
socket.send(action);
|
||||
if (typeof param === 'string') {
|
||||
socket.send(param);
|
||||
}
|
||||
}, (data) => {
|
||||
println(data);
|
||||
}).then(() => {
|
||||
println("Connection closed.", true)
|
||||
close();
|
||||
}).catch(() => {
|
||||
println("Connection errored.", true)
|
||||
close();
|
||||
});
|
||||
});
|
||||
})
|
||||
|
||||
const remote_link = document.getElementsByClassName('remote-link')
|
||||
Array.from(remote_link).forEach((element) => {
|
||||
const action = element.getAttribute('data-action') as string;
|
||||
const param = element.getAttribute('data-params') as string | undefined;
|
||||
const params = param?.split(" ");
|
||||
|
||||
element.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
getValue(action, params).then(v => {
|
||||
window.open(v);
|
||||
}).catch(e => {
|
||||
console.error(e);
|
||||
})
|
||||
});
|
||||
})
|
||||
|
||||
async function getValue(action: string, params?: Array<string>): Promise<any> {
|
||||
return new Promise((rs, rj) => {
|
||||
let buffer = "";
|
||||
var resolve = function() {
|
||||
const index = buffer.indexOf('\n')
|
||||
if (index < 0) {
|
||||
rj("invalid buffer");
|
||||
return
|
||||
}
|
||||
|
||||
// check that the server sent back true
|
||||
const ok = buffer.substring(0, index) === 'true';
|
||||
if(!ok) {
|
||||
rj(buffer);
|
||||
return
|
||||
}
|
||||
|
||||
// parse the rest as json
|
||||
const value = JSON.parse(buffer.substring(index+1))
|
||||
rs(value);
|
||||
}
|
||||
|
||||
connectSocket((socket) => {
|
||||
socket.send(action);
|
||||
if (params) {
|
||||
params.forEach(p => socket.send(p))
|
||||
}
|
||||
}, (data) => {
|
||||
buffer += data + "\n";
|
||||
}).then(() => {
|
||||
resolve();
|
||||
}).catch(() => {
|
||||
buffer = "false\n";
|
||||
resolve();
|
||||
});
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
export default function connectSocket(onOpen: (socket: WebSocket) => void, onData: (data: any) => void): Promise<CloseEvent> {
|
||||
return new Promise((rs, rj) => {
|
||||
const socket = new WebSocket(location.href.replace('http', 'ws'));
|
||||
|
||||
socket.onclose = rs;
|
||||
socket.onerror = rj;
|
||||
|
||||
socket.onmessage = (ev) => onData(ev.data)
|
||||
socket.onopen = () => onOpen(socket);
|
||||
});
|
||||
}
|
||||
41
internal/dis/component/server/assets/static.go
Normal file
41
internal/dis/component/server/assets/static.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// Package static implements serving of fully static resources
|
||||
package assets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
)
|
||||
|
||||
type Static struct {
|
||||
component.Base
|
||||
}
|
||||
|
||||
var (
|
||||
_ component.Routeable = (*Static)(nil)
|
||||
)
|
||||
|
||||
func (*Static) Routes() component.Routes {
|
||||
return component.Routes{
|
||||
Prefix: Public,
|
||||
|
||||
CSRF: false,
|
||||
}
|
||||
}
|
||||
|
||||
//go:embed dist
|
||||
var staticFS embed.FS
|
||||
|
||||
func (static *Static) HandleRoute(ctx context.Context, route string) (http.Handler, error) {
|
||||
// take the filesystem
|
||||
fs, err := fs.Sub(staticFS, "dist")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// and serve it
|
||||
return http.StripPrefix(route, http.FileServer(http.FS(fs))), nil
|
||||
}
|
||||
23
internal/dis/component/server/assets/templates.go
Normal file
23
internal/dis/component/server/assets/templates.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package assets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
)
|
||||
|
||||
//go:embed "templates/*.html"
|
||||
var templates embed.FS
|
||||
|
||||
var (
|
||||
shared *template.Template = template.Must(template.ParseFS(templates, "templates/*.html"))
|
||||
)
|
||||
|
||||
// NewSharedTemplate creates a new template with the given name.
|
||||
// It will be able to make use of shared templates as well as functions.
|
||||
func NewSharedTemplate(name string) *template.Template {
|
||||
new := template.New(name)
|
||||
for _, template := range shared.Templates() {
|
||||
new.AddParseTree(template.Tree.Name, template.Tree.Copy())
|
||||
}
|
||||
return new
|
||||
}
|
||||
139
internal/dis/component/server/assets/templates/_anal.html
Normal file
139
internal/dis/component/server/assets/templates/_anal.html
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<div class="pure-u-1-1">
|
||||
<h2 id="structs">Structs</h2>
|
||||
</div>
|
||||
|
||||
{{ range $name, $comp := .Components }}
|
||||
<div class="pure-u-1-1" id="{{ $name }}">
|
||||
<div class="padding">
|
||||
<div class="overflow">
|
||||
<table class="pure-table pure-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="3">
|
||||
{{ $name }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Groups }}
|
||||
<tr>
|
||||
<td>
|
||||
Implements
|
||||
</td>
|
||||
<td colspan="2">
|
||||
<code><a href="#{{.}}">{{ . }}</a></code><br />
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ range $name, $comp := .CFields }}
|
||||
<tr>
|
||||
<td>Component Pointer</td>
|
||||
<td>
|
||||
<code>{{ $name }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code><a href="#{{ $comp }}">{{ $comp }}</a></code>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ range $name, $comp := .DCFields }}
|
||||
<tr>
|
||||
<td>Component Pointer</td>
|
||||
<td>
|
||||
<code>Dependencies/{{ $name }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code><a href="#{{ $comp }}">{{ $comp }}</a></code>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ range $name, $iface := .IFields }}
|
||||
<tr>
|
||||
<td>Interface Slice</td>
|
||||
<td>
|
||||
<code>{{ $name }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code><a href="#{{ $iface }}">[]{{ $iface }}</a></code>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ range $name, $iface := .DIFields }}
|
||||
<tr>
|
||||
<td>Interface Slice</td>
|
||||
<td>
|
||||
<code>Dependencies/{{ $name }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code><a href="#{{ $iface }}">[]{{ $iface }}</a></code>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ range $name, $sig := $comp.Methods }}
|
||||
<tr>
|
||||
<td>
|
||||
Method
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ $name }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ $sig }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<div class="pure-u-1-1">
|
||||
<h2 id="interfaces">Interfaces</h2>
|
||||
</div>
|
||||
|
||||
{{ range $name, $group := .Groups }}
|
||||
<div class="pure-u-1-1" id="{{ $name }}">
|
||||
<div class="padding">
|
||||
|
||||
<div class="overflow">
|
||||
<table class="pure-table pure-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="3">
|
||||
{{ $name }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range $name, $sig := $group.Methods }}
|
||||
<tr>
|
||||
<td>
|
||||
Method
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ $name }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ $sig }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ range $group.Components }}
|
||||
<tr>
|
||||
<td>
|
||||
Implemented By
|
||||
</td>
|
||||
<td colspan="2">
|
||||
<code><a href="#{{.}}">{{ . }}</a></code>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
56
internal/dis/component/server/assets/templates/_base.html
Normal file
56
internal/dis/component/server/assets/templates/_base.html
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta charset="utf-8">
|
||||
|
||||
<title>{{ block "title" . }}WissKI Distillery{{ end }}</title>
|
||||
{{ block "styles" . }}styles{{ end }}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{{ .BaseContext.DoInitCheck }}
|
||||
<nav class="pure-menu pure-menu-horizontal">
|
||||
<ul class="pure-menu-list" role="menubar">
|
||||
{{ range .BaseContext.Menu }}
|
||||
<li class="pure-menu-item{{ if .Active }} pure-menu-selected{{ end }}">
|
||||
<a href="{{ .Path }}" class="pure-menu-link">{{ .Title }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</nav>
|
||||
<nav class="breadcrumbs" role="navigation" aria-label="Breadcrumbs">
|
||||
{{ range .BaseContext.Crumbs }}
|
||||
<a class="{{ if .Active }}active{{ end }}" href="{{ .Path }}">{{ .Title }}</a>
|
||||
{{ end }}
|
||||
</nav>
|
||||
|
||||
<header>
|
||||
<h1 id="top">{{ template "title" . }}</h1>
|
||||
{{ if .BaseContext.Actions }}
|
||||
<div class="pure-button-group" role="group" aria-label="Actions">
|
||||
{{ range .BaseContext.Actions }}
|
||||
<a href="{{ .Path }}" class="pure-button{{ if eq .Priority -1 }} pure-button-small{{end}}">{{ .Title }}</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</header>
|
||||
<main>
|
||||
<div class="pure-g">
|
||||
{{ block "content" . }}content{{ end }}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
{{ block "@custom/footer" .BaseContext }}
|
||||
<div style="z-index:10000;position:fixed;top:0;left:0;width:100vh;height:100vw;background:red;text-align:center;padding:10vh 10vw;font-size:xx-large;font-weight:bold">
|
||||
<code>.Templating.Template()</code> not called
|
||||
</div>
|
||||
{{ end }}
|
||||
</footer>
|
||||
|
||||
|
||||
{{ block "scripts" . }}scripts{{ end }}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
30
internal/dis/component/server/assets/templates/_form.html
Normal file
30
internal/dis/component/server/assets/templates/_form.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{{ template "_base.html" . }}
|
||||
{{ define "title" }}{{ block "form/title" . }}Form{{ end }}{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
<div class="pure-u-1">
|
||||
{{ block "form/extra" . }}<!-- no extra -->{{ end }}
|
||||
|
||||
<form class="pure-form pure-form-aligned" method="POST">
|
||||
<fieldset>
|
||||
<legend>{{ template "form/title" . }}</legend>
|
||||
|
||||
{{ block "form/message" . }}
|
||||
{{ $E := .Error }}
|
||||
{{ if not (eq $E "") }}
|
||||
<div class="pure-form-group">
|
||||
<p class="error-message">
|
||||
{{ $E }}
|
||||
</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ block "form/inside" . }}<!-- no inside -->{{ end }}
|
||||
{{ .Form }}
|
||||
<input type="submit" value="{{ block "form/button" .}}Submit{{ end }}" class="pure-button">
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{ end }}
|
||||
105
internal/dis/component/server/assets/tsconfig.json
Normal file
105
internal/dis/component/server/assets/tsconfig.json
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
"paths": {
|
||||
"~/*": ["./*"],
|
||||
}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
||||
1478
internal/dis/component/server/assets/yarn.lock
Normal file
1478
internal/dis/component/server/assets/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
14
internal/dis/component/server/cookies.go
Normal file
14
internal/dis/component/server/cookies.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package server
|
||||
|
||||
// CSRFCookie, CSRFCookieField, SessionCookie and SessionUserKey
|
||||
// hold the names of the cookies and fields used for specific cookies.
|
||||
//
|
||||
// These are intentionally kept short to conserve bandwidth.
|
||||
const (
|
||||
CSRFCookie = "F" // CSRF cookie sent on a lot of requests
|
||||
CSRFCookieField = "@" // form field name __should not be used by anything else__
|
||||
// to pay respect
|
||||
|
||||
SessionCookie = "x" // name of the cookie to use ; to doubt
|
||||
SessionUserKey = "@" // key within the session data to hold the username
|
||||
)
|
||||
143
internal/dis/component/server/cron/cron.go
Normal file
143
internal/dis/component/server/cron/cron.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
package cron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/timex"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type Cron struct {
|
||||
component.Base
|
||||
Dependencies struct {
|
||||
Tasks []component.Cronable
|
||||
}
|
||||
}
|
||||
|
||||
// Listen returns a channel that listens for triggers in the current process.
|
||||
// It is intended to be passed to Start.
|
||||
func (control *Cron) Listen(ctx context.Context) (<-chan struct{}, func()) {
|
||||
var (
|
||||
signals = make(chan os.Signal, 1)
|
||||
notify = make(chan struct{}, 1)
|
||||
)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-signals:
|
||||
notify <- struct{}{}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
signal.Notify(signals, syscall.SIGHUP)
|
||||
return notify, func() {
|
||||
signal.Ignore(syscall.SIGHUP)
|
||||
}
|
||||
}
|
||||
|
||||
// Once immediatly runs all cron jobs in the current thread.
|
||||
// Once returns once all cron jobs have returned.
|
||||
//
|
||||
// Once should not be called concurrently with Cron.
|
||||
func (control *Cron) Once(ctx context.Context) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(control.Dependencies.Tasks))
|
||||
|
||||
zerolog.Ctx(ctx).Info().Time("time", time.Now()).Msg("Starting Cron")
|
||||
|
||||
for _, task := range control.Dependencies.Tasks {
|
||||
go func(task component.Cronable) {
|
||||
defer wg.Done()
|
||||
|
||||
name := task.TaskName()
|
||||
|
||||
start := time.Now()
|
||||
zerolog.Ctx(ctx).Info().Str("task", name).Time("time", start).Msg("Calling Cron()")
|
||||
|
||||
panicked, panik, err := func() (panicked bool, panik any, err error) {
|
||||
defer func() {
|
||||
panik = recover()
|
||||
}()
|
||||
|
||||
panicked = true
|
||||
err = task.Cron(ctx)
|
||||
panicked = false
|
||||
|
||||
return
|
||||
}()
|
||||
|
||||
took := time.Since(start)
|
||||
|
||||
switch {
|
||||
case !panicked:
|
||||
zerolog.Ctx(ctx).Err(err).Str("task", name).Dur("took", took).Msg("Finished Cron()")
|
||||
case panicked:
|
||||
zerolog.Ctx(ctx).Error().Str("task", name).Dur("took", took).Str("panic", fmt.Sprint(panik)).Msg("Finished Cron()")
|
||||
}
|
||||
}(task)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
zerolog.Ctx(ctx).Info().Time("time", time.Now()).Msg("Finished Cron")
|
||||
}
|
||||
|
||||
// Start invokes all cron jobs regularly, waiting between invocations as specified in configuration.
|
||||
//
|
||||
// A first run is invoked immediatly.
|
||||
// The call to Start returns after the first invocation of all cron tasks.
|
||||
//
|
||||
// The returned channel is closed once no more cron tasks are active.
|
||||
func (control *Cron) Start(ctx context.Context, signal <-chan struct{}) <-chan struct{} {
|
||||
zerolog.Ctx(ctx).Info().Dur("interval", control.Config.CronInterval).Msg("Scheduling Cron() tasks")
|
||||
|
||||
// run runs cron tasks with the configured timeout
|
||||
run := func() {
|
||||
ctx, done := context.WithTimeout(ctx, control.Config.CronInterval)
|
||||
defer done()
|
||||
|
||||
control.Once(ctx)
|
||||
}
|
||||
|
||||
cleanup := make(chan struct{}) // closed once we have finished running everything
|
||||
|
||||
// start a new xgoroutine to run cron tasks
|
||||
go func() {
|
||||
defer close(cleanup)
|
||||
|
||||
zerolog.Ctx(ctx).Debug().Msg("Cron() starting first run")
|
||||
run()
|
||||
zerolog.Ctx(ctx).Debug().Msg("Cron() beginnning scheduling")
|
||||
|
||||
timer := timex.NewTimer()
|
||||
for {
|
||||
timex.StopTimer(timer)
|
||||
timer.Reset(control.Config.CronInterval)
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
zerolog.Ctx(ctx).Debug().Msg("Cron() timer fired")
|
||||
case <-signal:
|
||||
zerolog.Ctx(ctx).Debug().Msg("Cron() received signal")
|
||||
case <-ctx.Done():
|
||||
timex.StopTimer(timer)
|
||||
return
|
||||
}
|
||||
|
||||
run()
|
||||
}
|
||||
}()
|
||||
|
||||
// and return the cleanup channel
|
||||
return cleanup
|
||||
}
|
||||
85
internal/dis/component/server/home/home.go
Normal file
85
internal/dis/component/server/home/home.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package home
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templates"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/status"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/lazy"
|
||||
)
|
||||
|
||||
type Home struct {
|
||||
component.Base
|
||||
Dependencies struct {
|
||||
Templating *templates.Templating
|
||||
Instances *instances.Instances
|
||||
}
|
||||
|
||||
instanceNames lazy.Lazy[map[string]struct{}] // instance names
|
||||
homeInstances lazy.Lazy[[]status.WissKI] // list of home instances (updated via cron)
|
||||
}
|
||||
|
||||
var (
|
||||
_ component.Routeable = (*Home)(nil)
|
||||
)
|
||||
|
||||
func (*Home) Routes() component.Routes {
|
||||
return component.Routes{
|
||||
Prefix: "/",
|
||||
MatchAllDomains: true,
|
||||
CSRF: false,
|
||||
|
||||
MenuTitle: "WissKI Distillery",
|
||||
MenuPriority: component.MenuHome,
|
||||
}
|
||||
}
|
||||
|
||||
func (home *Home) HandleRoute(ctx context.Context, route string) (http.Handler, error) {
|
||||
// generate a default handler
|
||||
dflt, err := home.loadRedirect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dflt.Fallback = home.publicHandler(ctx)
|
||||
|
||||
return http.HandlerFunc(func(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:
|
||||
dflt.ServeHTTP(w, r)
|
||||
}
|
||||
}), 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 {
|
||||
// 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)
|
||||
}
|
||||
86
internal/dis/component/server/home/instances.go
Normal file
86
internal/dis/component/server/home/instances.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
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
|
||||
}
|
||||
44
internal/dis/component/server/home/public.go
Normal file
44
internal/dis/component/server/home/public.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package home
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/assets"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templates"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/status"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
)
|
||||
|
||||
//go:embed "public.html"
|
||||
var publicHTML []byte
|
||||
var publicTemplate = templates.Parse[publicContext]("public.html", publicHTML, assets.AssetsDefault)
|
||||
|
||||
type publicContext struct {
|
||||
templates.BaseContext
|
||||
|
||||
Instances []status.WissKI
|
||||
SelfRedirect string
|
||||
}
|
||||
|
||||
func (home *Home) publicHandler(ctx context.Context) http.Handler {
|
||||
tpl := publicTemplate.Prepare(home.Dependencies.Templating, templates.BaseContextGaps{
|
||||
Crumbs: []component.MenuItem{
|
||||
{Title: "WissKI Distillery", Path: "/"},
|
||||
},
|
||||
})
|
||||
return tpl.HTMLHandler(func(r *http.Request) (pc publicContext, err error) {
|
||||
// only act on the root path!
|
||||
if strings.TrimSuffix(r.URL.Path, "/") != "" {
|
||||
return pc, httpx.ErrNotFound
|
||||
}
|
||||
|
||||
pc.Instances = home.homeInstances.Get(nil)
|
||||
pc.SelfRedirect = home.Config.SelfRedirect.String()
|
||||
|
||||
return
|
||||
})
|
||||
}
|
||||
36
internal/dis/component/server/home/public.html
Normal file
36
internal/dis/component/server/home/public.html
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{{ template "_base.html" . }}
|
||||
{{ define "title" }}WissKI Distillery{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
{{ block "@custom/about" . }}
|
||||
<div class="pure-u-1">
|
||||
<p>
|
||||
For more information, see <a href="{{ .SelfRedirect }}">{{ .SelfRedirect }}</a>.
|
||||
</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<div class="pure-u-1">
|
||||
<h2>WissKIs on this Distillery</h2>
|
||||
</div>
|
||||
|
||||
{{range .Instances}}
|
||||
{{ if and .Running (not .NoPrefixes) }}
|
||||
<div class="pure-u-1 pure-u-md-1-3">
|
||||
<h3>{{.Slug}}</h3>
|
||||
<p>
|
||||
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer" class="wisskilink">{{.URL}}</a><br>
|
||||
<small>
|
||||
{{ .Statistics.Bundles.Summary }}
|
||||
|
||||
{{ $edit := .Statistics.Bundles.LastEdit }}
|
||||
{{ if $edit.Valid }}
|
||||
<br />
|
||||
last edited {{ $edit.Time.Format "2006-01-02T15:04:05Z07:00" }}
|
||||
{{ end }}
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
103
internal/dis/component/server/home/redirect.go
Normal file
103
internal/dis/component/server/home/redirect.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
package home
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (home *Home) loadRedirect(ctx context.Context) (redirect Redirect, err error) {
|
||||
if redirect.Overrides == nil {
|
||||
redirect.Overrides = make(map[string]string)
|
||||
}
|
||||
|
||||
delete(redirect.Overrides, "") // make sure there is no root redirect
|
||||
|
||||
redirect.Absolute = false
|
||||
redirect.Permanent = false
|
||||
|
||||
// load the overrides file
|
||||
overrides, err := home.Environment.Open(home.Config.SelfOverridesFile)
|
||||
if err != nil {
|
||||
return redirect, err
|
||||
}
|
||||
defer overrides.Close()
|
||||
|
||||
// decode the overrides file
|
||||
if err := json.NewDecoder(overrides).Decode(&redirect.Overrides); err != nil {
|
||||
return redirect, err
|
||||
}
|
||||
|
||||
// and return!
|
||||
return redirect, nil
|
||||
}
|
||||
|
||||
// Redirect implements a redirect server that redirects all requests.
|
||||
// It implements http.Handler.
|
||||
type Redirect struct {
|
||||
// Target is the target URL to redirect to.
|
||||
Target string
|
||||
|
||||
// Fallback is used when target is the empty string.
|
||||
Fallback http.Handler
|
||||
|
||||
// Absolute determines if the request path should be appended to the target URL when redirecting.
|
||||
// By default this path is always appended, set Absolute to true to prevent this.
|
||||
Absolute bool
|
||||
|
||||
// Overrides is a map from paths to URLs that should override the default target.
|
||||
Overrides map[string]string
|
||||
|
||||
// Permanent determines if the redirect responses issued should return
|
||||
// Permanent Redirect (Status Code 308) or Temporary Redirect (Status Code 307).
|
||||
Permanent bool
|
||||
}
|
||||
|
||||
// Redirect determines the redirect URL for a specific incoming request
|
||||
// If it returns the empty string, the fallback is used.
|
||||
func (redirect Redirect) Redirect(r *http.Request) string {
|
||||
// if we have an override for this URL, use it immediatly
|
||||
url := strings.TrimSuffix(r.URL.Path, "/")
|
||||
if override, ok := redirect.Overrides[url]; ok {
|
||||
return override
|
||||
}
|
||||
|
||||
if redirect.Target == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// if we are in absolute redirect mode, always return the absolute URL
|
||||
if redirect.Absolute {
|
||||
return redirect.Target
|
||||
}
|
||||
|
||||
// return the target + the redirected URL
|
||||
dest := strings.TrimSuffix(redirect.Target, "/") + r.URL.Path
|
||||
if len(r.URL.RawQuery) > 0 {
|
||||
dest += "?" + r.URL.RawQuery
|
||||
}
|
||||
return dest
|
||||
}
|
||||
|
||||
// ServeHTTP implements the http.Handler interface and redirects a single request to redirect.Target.
|
||||
func (redirect Redirect) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
dest := redirect.Redirect(r)
|
||||
if dest == "" {
|
||||
if redirect.Fallback == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
redirect.Fallback.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// determine if we are temporary or permanent redirect
|
||||
status := http.StatusTemporaryRedirect
|
||||
if redirect.Permanent {
|
||||
status = http.StatusPermanentRedirect
|
||||
}
|
||||
|
||||
// and do the redirect
|
||||
http.Redirect(w, r, dest, status)
|
||||
}
|
||||
67
internal/dis/component/server/legal/legal.go
Normal file
67
internal/dis/component/server/legal/legal.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package legal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/cli"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/assets"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templates"
|
||||
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
type Legal struct {
|
||||
component.Base
|
||||
Dependencies struct {
|
||||
Static *assets.Static
|
||||
Templating *templates.Templating
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ component.Routeable = (*Legal)(nil)
|
||||
)
|
||||
|
||||
//go:embed "legal.html"
|
||||
var legalHTML []byte
|
||||
var legalTemplate = templates.Parse[legalContext]("legal.html", legalHTML, assets.AssetsDefault)
|
||||
|
||||
type legalContext struct {
|
||||
templates.BaseContext
|
||||
|
||||
LegalNotices string
|
||||
|
||||
CSRFCookie string
|
||||
SessionCookie string
|
||||
AssetsDisclaimer string
|
||||
}
|
||||
|
||||
func (legal *Legal) Routes() component.Routes {
|
||||
return component.Routes{
|
||||
Prefix: "/legal/",
|
||||
Exact: true,
|
||||
|
||||
CSRF: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (legal *Legal) HandleRoute(ctx context.Context, route string) (http.Handler, error) {
|
||||
tpl := legalTemplate.Prepare(legal.Dependencies.Templating, templates.BaseContextGaps{
|
||||
Crumbs: []component.MenuItem{
|
||||
{Title: "Legal", Path: "/legal/"},
|
||||
},
|
||||
})
|
||||
|
||||
return tpl.HTMLHandler(func(r *http.Request) (lc legalContext, err error) {
|
||||
lc.LegalNotices = cli.LegalNotices
|
||||
|
||||
lc.CSRFCookie = server.CSRFCookie
|
||||
lc.SessionCookie = server.SessionCookie
|
||||
lc.AssetsDisclaimer = assets.Disclaimer
|
||||
|
||||
return
|
||||
}), nil
|
||||
}
|
||||
72
internal/dis/component/server/legal/legal.html
Normal file
72
internal/dis/component/server/legal/legal.html
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
{{ template "_base.html" . }}
|
||||
{{ define "title" }}Legal{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
<div class="pure-u-1">
|
||||
<h2 id="cookies">Cookie Usage</h2>
|
||||
|
||||
<p>
|
||||
Parts of this site use cookies for essential purposes.
|
||||
<a href="https://en.wikipedia.org/wiki/HTTP_cookie">Wikipedia</a> says that
|
||||
</p>
|
||||
<blockquote>
|
||||
<p>A […] cookie (also called web cookie, Internet cookie, browser cookie, or simply cookie) is a small piece of data sent from a website and stored on the user’s computer by the user’s web browser while the user is browsing.</p>
|
||||
</blockquote>
|
||||
<p>
|
||||
This site only uses cookies where necessary; in particular they are only used on access protected sites.
|
||||
Public sites are cookie-free.
|
||||
For signed in users only two cookies are used.
|
||||
|
||||
<ol>
|
||||
<li>
|
||||
The cookie named <code>{{ .CSRFCookie }}</code> is used to prevent <a href="https://en.wikipedia.org/wiki/Cross-site_request_forgery">Cross-site request forgery</a>.
|
||||
</li>
|
||||
<li>
|
||||
The cookie named <code>{{ .SessionCookie }}</code> is used to track a distillery user session, so that a user does not constantly have to login again.
|
||||
It is automatically deleted once a user signs out.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
Neither cookie is used beyond the purposes they are required for.
|
||||
In particular, they are not used for analytics or any other kind of tracking.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pure-u-1">
|
||||
<h2 id="notices">Legal Notices</h2>
|
||||
|
||||
<ul>
|
||||
<li><a href="#license.backend">Backend</a></li>
|
||||
<li><a href="#license.frontend">Frontend</a></li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
This site is powered by the <a href="https://github.com/FAU-CDI/wisski-distillery" target="_blank" rel="noopener noreferer">WissKI Distillery</a>.
|
||||
The project is licensed under the terms of the AGPL Version 3.0 License.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pure-u-1">
|
||||
<h3 id="license.backend">Backend</h2>
|
||||
|
||||
<p>
|
||||
<small><a href="#notices">Back to Notices</a></small>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The backend may contain code from the following projects:
|
||||
</p>
|
||||
|
||||
<pre>{{ .LegalNotices }}</pre>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="pure-u-1">
|
||||
<h3 id="license.frontend">Frontend</h2>
|
||||
|
||||
<p>
|
||||
<small><a href="#notices">Back to Notices</a></small>
|
||||
</p>
|
||||
|
||||
<pre>{{ .AssetsDisclaimer }}</pre>
|
||||
</div>
|
||||
{{ end }}
|
||||
7
internal/dis/component/server/news/NEWS/2022-09-08-go.md
Normal file
7
internal/dis/component/server/news/NEWS/2022-09-08-go.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
title: Migration to go
|
||||
date: 2022-09-08
|
||||
---
|
||||
|
||||
- We have ported the distillery from a set of bash scripts to a self-contained go executable
|
||||
- This makes future development easier, and allows us to develop new features easier
|
||||
10
internal/dis/component/server/news/NEWS/2022-09-09-admin.md
Normal file
10
internal/dis/component/server/news/NEWS/2022-09-09-admin.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
title: Addition of an administrative server
|
||||
date: 2022-09-09
|
||||
---
|
||||
|
||||
- We have added a new web route under `/dis/`
|
||||
- Allows administrators to manage WissKI Instances and see their status
|
||||
- Administrators can e.g. download pathbuilders and make backups and snapshots
|
||||
- At this point it is only for administrators
|
||||
- A future (public) server with statistics will follow
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
title: Addition of a Global Distillery Resolver
|
||||
date: 2022-10-05
|
||||
---
|
||||
|
||||
- We have added a global WissKI Resolver, that functions similarly to the WissKI resolver under `/wisski/`.
|
||||
- It can resolve WissKI URIs for the entire distillery, and redirect to their view page.
|
||||
- Can be called exactly like the `/wisski/get?uri=` route of individual WissKIs, but toplevel on the distillery.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
title: Migration to Traefik & Support for HTTP3
|
||||
date: 2022-10-12
|
||||
---
|
||||
|
||||
- We have migrated the entry point from nginx to [traefik](https://traefik.io/traefik/)
|
||||
- This enables much cleaner support for automatically fetching and renewing SSL certificates
|
||||
- It is now possible to turn on http3 support for the entire distillery
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
title: Refactored SSH Support
|
||||
date: 2022-11-12
|
||||
---
|
||||
|
||||
- Fully refactored ssh for users to use a real OpenSSH Server along with a small custom proxy in between
|
||||
- It is now possible for developers to directly use e.g. [VSCode Remote SSH](https://code.visualstudio.com/docs/remote/ssh) to develop new WissKI features directly on the distillery.
|
||||
- No configuration beyond regular ssh access is neccessary
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
title: Showing Statistics
|
||||
date: 2022-11-16
|
||||
---
|
||||
|
||||
- The distillery nows shows generic statistics on the public homepage
|
||||
- detailed statistics can be found on the admin interface
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
title: Login using Distillery Administration
|
||||
date: 2022-11-23
|
||||
---
|
||||
|
||||
- The admin interface now allows login to individual user accounts
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
title: Automatic Password Checking
|
||||
date: 2022-11-25
|
||||
---
|
||||
|
||||
- Implemented automatic password checking
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: User And Instance Management
|
||||
date: 2023-01-07
|
||||
---
|
||||
|
||||
- the concept of distillery user accounts has been added
|
||||
- their accounts have a password as well as TOTP
|
||||
- users can manage their own account details
|
||||
- administrators can reset user passwords, and disable TOTP
|
||||
- distillery accounts can be linked to multiple drupal accounts
|
||||
- users can sign into the account without entering further passwords
|
||||
- users must have two-factor-authentication enabled to use this functionality
|
||||
- administrators have access to the distillery admin panel
|
||||
- the functionality to manage distillery accounts has been added
|
||||
- the functionality to link distillery and drupal accounts has been added
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
title: News on the homepage
|
||||
date: 2023-01-09
|
||||
---
|
||||
|
||||
- we are now linking and showing the news section from the homepage
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
title: Reworked SSH key support
|
||||
date: 2023-01-15
|
||||
---
|
||||
|
||||
- reworked and added ssh key management to the server
|
||||
- users can now add and remove ssh keys to their account
|
||||
- each user with an admin grant for a specific instance has ssh access via their keys
|
||||
- distillery administrators have implicit access to all instances
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
title: Removing instances from admin interface
|
||||
date: 2023-01-16
|
||||
---
|
||||
|
||||
- added an option to purge and remove instances from the admin page
|
||||
140
internal/dis/component/server/news/news.go
Normal file
140
internal/dis/component/server/news/news.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
package news
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/assets"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templates"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/yuin/goldmark"
|
||||
gmmeta "github.com/yuin/goldmark-meta"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
type News struct {
|
||||
component.Base
|
||||
Dependencies struct {
|
||||
Templating *templates.Templating
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ component.Routeable = (*News)(nil)
|
||||
)
|
||||
|
||||
func (*News) Routes() component.Routes {
|
||||
return component.Routes{
|
||||
Prefix: "/news/",
|
||||
Exact: true,
|
||||
CSRF: false,
|
||||
|
||||
MenuTitle: "News",
|
||||
MenuPriority: component.MenuNews,
|
||||
}
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
ID string
|
||||
Date time.Time
|
||||
Title string
|
||||
Content template.HTML
|
||||
}
|
||||
|
||||
func (item *Item) parse(path string, builder *strings.Builder) error {
|
||||
builder.Reset()
|
||||
|
||||
// open file
|
||||
content, err := fs.ReadFile(newsFS, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// parse and read metadata
|
||||
reader := goldmark.New(goldmark.WithExtensions(
|
||||
gmmeta.Meta,
|
||||
))
|
||||
|
||||
context := parser.NewContext()
|
||||
if err := reader.Convert(content, builder, parser.WithContext(context)); err != nil {
|
||||
return err
|
||||
}
|
||||
meta := gmmeta.Get(context)
|
||||
|
||||
// read title
|
||||
item.Title, _ = meta["title"].(string)
|
||||
|
||||
// read date
|
||||
date, _ := meta["date"].(string)
|
||||
item.Date, err = time.Parse("2006-01-02", date)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// write content
|
||||
item.Content = template.HTML(builder.String())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//go:embed "NEWS/*.md"
|
||||
var newsFS embed.FS
|
||||
|
||||
// Items returns a list of all news items
|
||||
func Items() ([]Item, error) {
|
||||
var builder strings.Builder
|
||||
|
||||
files, err := fs.Glob(newsFS, "NEWS/*.md")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]Item, len(files))
|
||||
for i, file := range files {
|
||||
items[i].ID = file[len("NEWS/") : len(file)-len(".md")]
|
||||
if err := items[i].parse(file, &builder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(items, func(a, b Item) bool {
|
||||
return !a.Date.Before(b.Date)
|
||||
})
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
//go:embed "news.html"
|
||||
var newsHTML []byte
|
||||
var newsTemplate = templates.Parse[newsContext]("news.html", newsHTML, assets.AssetsDefault)
|
||||
|
||||
type newsContext struct {
|
||||
templates.BaseContext
|
||||
Items []Item
|
||||
}
|
||||
|
||||
// HandleRoute returns the handler for the requested path
|
||||
func (news *News) HandleRoute(ctx context.Context, path string) (http.Handler, error) {
|
||||
tpl := newsTemplate.Prepare(news.Dependencies.Templating, templates.BaseContextGaps{
|
||||
Crumbs: []component.MenuItem{
|
||||
{Title: "News", Path: "/news/"},
|
||||
},
|
||||
})
|
||||
|
||||
items, itemsErr := Items()
|
||||
if itemsErr != nil {
|
||||
zerolog.Ctx(ctx).Err(itemsErr).Msg("Unable to load news items")
|
||||
}
|
||||
|
||||
return tpl.HTMLHandler(func(r *http.Request) (nc newsContext, err error) {
|
||||
nc.Items, err = items, itemsErr
|
||||
return
|
||||
}), nil
|
||||
}
|
||||
18
internal/dis/component/server/news/news.html
Normal file
18
internal/dis/component/server/news/news.html
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{{ template "_base.html" . }}
|
||||
{{ define "title" }}News{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
|
||||
<div class="pure-u-1">
|
||||
This page contains news items from the distillery.
|
||||
</div>
|
||||
|
||||
{{range .Items}}
|
||||
<div class="pure-u-1">
|
||||
<h2 id="{{.ID}}">{{.Title}}</h3>
|
||||
<b>{{ .Date.Format "2006-01-02" }}</b>
|
||||
{{ .Content }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
||||
11
internal/dis/component/server/server.env
Normal file
11
internal/dis/component/server/server.env
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
HOST_RULE=${HOST_RULE}
|
||||
|
||||
CONFIG_PATH=${CONFIG_PATH}
|
||||
DEPLOY_ROOT=${DEPLOY_ROOT}
|
||||
SELF_OVERRIDES_FILE=${SELF_OVERRIDES_FILE}
|
||||
SELF_RESOLVER_BLOCK_FILE=${SELF_RESOLVER_BLOCK_FILE}
|
||||
|
||||
DOCKER_NETWORK_NAME=${DOCKER_NETWORK_NAME}
|
||||
HTTPS_ENABLED=${HTTPS_ENABLED}
|
||||
|
||||
CUSTOM_ASSETS_PATH=${CUSTOM_ASSETS_PATH}
|
||||
122
internal/dis/component/server/server.go
Normal file
122
internal/dis/component/server/server.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templates"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/cancel"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/mux"
|
||||
"github.com/gorilla/csrf"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// Server represents the running control server.
|
||||
type Server struct {
|
||||
component.Base
|
||||
Dependencies struct {
|
||||
Routeables []component.Routeable
|
||||
Cronables []component.Cronable
|
||||
|
||||
Templating *templates.Templating
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ component.Installable = (*Server)(nil)
|
||||
)
|
||||
|
||||
// Server returns an http.Mux that implements the main server instance.
|
||||
// The server may spawn background tasks, but these should be terminated once context closes.
|
||||
//
|
||||
// Logging messages are directed to progress
|
||||
func (server *Server) Server(ctx context.Context, progress io.Writer) (public http.Handler, internal http.Handler, err error) {
|
||||
logger := zerolog.Ctx(ctx)
|
||||
|
||||
var publicM, internalM mux.Mux[component.RouteContext]
|
||||
publicM.Context = func(r *http.Request) component.RouteContext {
|
||||
slug, ok := server.Still.Config.SlugFromHost(r.Host)
|
||||
return component.RouteContext{
|
||||
DefaultDomain: slug == "" && ok,
|
||||
}
|
||||
}
|
||||
publicM.Panic = func(panic any, w http.ResponseWriter, r *http.Request) {
|
||||
// log the panic
|
||||
logger.Error().
|
||||
Str("panic", fmt.Sprint(panic)).
|
||||
Str("path", r.URL.Path).
|
||||
Msg("panic serving handler")
|
||||
|
||||
// and send an internal server error
|
||||
httpx.TextInterceptor.Fallback.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// setup the internal server identically
|
||||
internalM.Panic = publicM.Panic
|
||||
internalM.Context = publicM.Context
|
||||
|
||||
// create a csrf protector
|
||||
csrfProtector := server.csrf()
|
||||
|
||||
// iterate over all the handler
|
||||
for _, s := range server.Dependencies.Routeables {
|
||||
routes := s.Routes()
|
||||
zerolog.Ctx(ctx).Info().
|
||||
Str("Name", s.Name()).
|
||||
Str("Prefix", routes.Prefix).
|
||||
Strs("Aliases", routes.Aliases).
|
||||
Bool("Exact", routes.Exact).
|
||||
Bool("CSRF", routes.CSRF).
|
||||
Bool("Decorator", routes.Decorator != nil).
|
||||
Bool("Internal", routes.Internal).
|
||||
Bool("MatchAllDomains", routes.MatchAllDomains).
|
||||
Msg("mounting route")
|
||||
|
||||
// call the handler for the route
|
||||
handler, err := s.HandleRoute(ctx, routes.Prefix)
|
||||
if err != nil {
|
||||
zerolog.Ctx(ctx).Err(err).
|
||||
Str("Component", s.Name()).
|
||||
Str("Prefix", routes.Prefix).
|
||||
Msg("error mounting route")
|
||||
continue
|
||||
}
|
||||
|
||||
// decorate the handler
|
||||
handler = routes.Decorate(handler, csrfProtector)
|
||||
|
||||
// determine the predicate
|
||||
predicate := routes.Predicate(publicM.ContextOf)
|
||||
|
||||
// and add all the prefixes
|
||||
for _, prefix := range append([]string{routes.Prefix}, routes.Aliases...) {
|
||||
if routes.Internal {
|
||||
internalM.Add(prefix, predicate, routes.Exact, handler)
|
||||
} else {
|
||||
publicM.Add(prefix, predicate, routes.Exact, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apply the given context function
|
||||
public = httpx.WithContextWrapper(&publicM, func(rcontext context.Context) context.Context { return cancel.ValuesOf(rcontext, ctx) })
|
||||
internal = httpx.WithContextWrapper(&internalM, func(rcontext context.Context) context.Context { return cancel.ValuesOf(rcontext, ctx) })
|
||||
err = nil
|
||||
return
|
||||
}
|
||||
|
||||
// CSRF returns a CSRF handler for the given function
|
||||
func (server *Server) csrf() func(http.Handler) http.Handler {
|
||||
var opts []csrf.Option
|
||||
if !server.Config.HTTPSEnabled() {
|
||||
opts = append(opts, csrf.Secure(false))
|
||||
}
|
||||
opts = append(opts, csrf.SameSite(csrf.SameSiteStrictMode))
|
||||
opts = append(opts, csrf.CookieName(CSRFCookie))
|
||||
opts = append(opts, csrf.FieldName(CSRFCookieField))
|
||||
return csrf.Protect(server.Config.CSRFSecret(), opts...)
|
||||
}
|
||||
5
internal/dis/component/server/server/Dockerfile
Normal file
5
internal/dis/component/server/server/Dockerfile
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
FROM docker.io/library/docker:20.10-cli
|
||||
|
||||
COPY wdcli /wdcli
|
||||
EXPOSE 8888
|
||||
CMD ["/wdcli","--internal-in-docker","server","--bind","0.0.0.0:8888", "--internal-bind", "0.0.0.0:9999"]
|
||||
35
internal/dis/component/server/server/docker-compose.yml
Normal file
35
internal/dis/component/server/server/docker-compose.yml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
version: "3.7"
|
||||
|
||||
services:
|
||||
dis:
|
||||
build: .
|
||||
restart: always
|
||||
environment:
|
||||
CONFIG_PATH: ${CONFIG_PATH}
|
||||
labels:
|
||||
|
||||
- "traefik.enable=True"
|
||||
- "eu.wiss-ki.barrel.distillery=${DOCKER_NETWORK_NAME}"
|
||||
- "traefik.http.routers.control.rule=${HOST_RULE}"
|
||||
|
||||
- "traefik.http.routers.fallback.rule=HostRegexp(`{catchall:.*}`)"
|
||||
- "traefik.http.routers.fallback.priority=1"
|
||||
|
||||
- "traefik.http.routers.control.tls=${HTTPS_ENABLED}"
|
||||
- "traefik.http.routers.control.tls.certresolver=distillery"
|
||||
- "traefik.http.services.control.loadbalancer.server.port=8888"
|
||||
|
||||
|
||||
volumes:
|
||||
# TODO: Mount docker socket properly!
|
||||
- "/var/run/docker.sock:/var/run/docker.sock"
|
||||
- "${CONFIG_PATH}:${CONFIG_PATH}:ro"
|
||||
- "${DEPLOY_ROOT}:${DEPLOY_ROOT}:rw"
|
||||
- "${SELF_OVERRIDES_FILE}:${SELF_OVERRIDES_FILE}:ro"
|
||||
- "${SELF_RESOLVER_BLOCK_FILE}:${SELF_RESOLVER_BLOCK_FILE}:ro"
|
||||
- "${CUSTOM_ASSETS_PATH}:${CUSTOM_ASSETS_PATH}:ro"
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: ${DOCKER_NETWORK_NAME}
|
||||
external: true
|
||||
55
internal/dis/component/server/stack.go
Normal file
55
internal/dis/component/server/stack.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/bootstrap"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/environment"
|
||||
)
|
||||
|
||||
func (control Server) Path() string {
|
||||
return filepath.Join(control.Still.Config.DeployRoot, "core", "dis")
|
||||
}
|
||||
|
||||
//go:embed all:server server.env
|
||||
var resources embed.FS
|
||||
|
||||
func (server *Server) Stack(env environment.Environment) component.StackWithResources {
|
||||
return component.MakeStack(server, env, component.StackWithResources{
|
||||
Resources: resources,
|
||||
ContextPath: "server",
|
||||
EnvPath: "server.env",
|
||||
|
||||
EnvContext: map[string]string{
|
||||
"DOCKER_NETWORK_NAME": server.Config.DockerNetworkName,
|
||||
"HOST_RULE": server.Config.DefaultHostRule(),
|
||||
"HTTPS_ENABLED": server.Config.HTTPSEnabledEnv(),
|
||||
|
||||
"CONFIG_PATH": server.Config.ConfigPath,
|
||||
"DEPLOY_ROOT": server.Config.DeployRoot,
|
||||
|
||||
"SELF_OVERRIDES_FILE": server.Config.SelfOverridesFile,
|
||||
"SELF_RESOLVER_BLOCK_FILE": server.Config.SelfResolverBlockFile,
|
||||
|
||||
"CUSTOM_ASSETS_PATH": server.Dependencies.Templating.CustomAssetsPath(),
|
||||
},
|
||||
|
||||
CopyContextFiles: []string{bootstrap.Executable},
|
||||
})
|
||||
}
|
||||
|
||||
// Trigger triggers the active cron run to immediatly invoke cron.
|
||||
func (server *Server) Trigger(ctx context.Context, env environment.Environment) error {
|
||||
return server.Stack(env).Kill(ctx, io.Discard, "control", syscall.SIGHUP)
|
||||
}
|
||||
|
||||
func (server *Server) Context(parent component.InstallationContext) component.InstallationContext {
|
||||
return component.InstallationContext{
|
||||
bootstrap.Executable: server.Config.CurrentExecutable(server.Environment), // TODO: Does this make sense?
|
||||
}
|
||||
}
|
||||
22
internal/dis/component/server/templates/assets.go
Normal file
22
internal/dis/component/server/templates/assets.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
)
|
||||
|
||||
// CustomAssetsPath is the path custom assets are stored at
|
||||
func (tpl *Templating) CustomAssetsPath() string {
|
||||
return filepath.Join(tpl.Config.DeployRoot, "core", "assets")
|
||||
}
|
||||
|
||||
func (tpl *Templating) CustomAssetPath(name string) string {
|
||||
return filepath.Join(tpl.CustomAssetsPath(), name)
|
||||
}
|
||||
|
||||
func (tpl *Templating) BackupName() string { return "custom" }
|
||||
|
||||
func (tpl *Templating) Backup(context component.StagingContext) error {
|
||||
return context.CopyDirectory("", tpl.CustomAssetsPath())
|
||||
}
|
||||
102
internal/dis/component/server/templates/context.go
Normal file
102
internal/dis/component/server/templates/context.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
"github.com/gorilla/csrf"
|
||||
"github.com/tkw1536/goprogram/lib/reflectx"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
// baseContextName is the name of the [BaseContext] type
|
||||
var baseContextName = reflectx.TypeOf[BaseContext]().Name()
|
||||
|
||||
// BaseContext represents a context used by templates
|
||||
//
|
||||
// Other invocations might cause an error at runtime.
|
||||
type BaseContext struct {
|
||||
inited bool // has this context been inited?
|
||||
requestWasNil bool // was the passed request nil
|
||||
|
||||
GeneratedAt time.Time // time this page was generated at
|
||||
|
||||
// Menu and breadcrumbs
|
||||
Menu []component.MenuItem
|
||||
BaseContextGaps
|
||||
|
||||
CSRF template.HTML // CSRF Field
|
||||
}
|
||||
|
||||
// constants that are used in various parts of the template to render stuff
|
||||
const (
|
||||
errorPrefix template.HTML = `<div style="z-index:10000;position:fixed;top:0;left:0;width:100vh;height:100vw;background:red;text-align:center;padding:10vh 10vw;font-size:xx-large;font-weight:bold">`
|
||||
errorSuffix template.HTML = "</div>"
|
||||
|
||||
csrfError template.HTML = errorPrefix + "CSRF used but not provided" + errorSuffix
|
||||
initError template.HTML = errorPrefix + "<code>BaseContext.use()</code> not called" + errorSuffix
|
||||
requestNilError template.HTML = errorPrefix + "<code>BaseContext.use()</code> called with nil request" + errorSuffix
|
||||
)
|
||||
|
||||
type BaseContextGaps struct {
|
||||
Crumbs []component.MenuItem
|
||||
Actions []component.MenuItem
|
||||
}
|
||||
|
||||
func (bcg BaseContextGaps) clone() BaseContextGaps {
|
||||
return BaseContextGaps{
|
||||
Crumbs: slices.Clone(bcg.Crumbs),
|
||||
Actions: slices.Clone(bcg.Actions),
|
||||
}
|
||||
}
|
||||
|
||||
// update updates an embedded BaseContext field in context.
|
||||
func (tpl *Templating) update(context any, r *http.Request, bcg BaseContextGaps) *BaseContext {
|
||||
tc := reflect.ValueOf(context).
|
||||
Elem().FieldByName(baseContextName).Addr().
|
||||
Interface().(*BaseContext)
|
||||
|
||||
tc.inited = true
|
||||
tc.requestWasNil = r == nil
|
||||
|
||||
tc.GeneratedAt = time.Now().UTC()
|
||||
|
||||
// setup the CSRF field
|
||||
tc.CSRF = csrfError
|
||||
if r != nil {
|
||||
tc.CSRF = csrf.TemplateField(r)
|
||||
}
|
||||
|
||||
// build the menu
|
||||
tc.Menu = tpl.buildMenu(r)
|
||||
|
||||
// build the breadcrumbs
|
||||
tc.BaseContextGaps = bcg.clone()
|
||||
last := len(tc.Crumbs) - 1
|
||||
for i := range tc.Crumbs {
|
||||
tc.Crumbs[i].Active = i == last
|
||||
}
|
||||
|
||||
return tc
|
||||
}
|
||||
|
||||
// DoInitCheck is called by the template to check that the BaseContext was initialized properly
|
||||
func (bc BaseContext) DoInitCheck() template.HTML {
|
||||
if !bc.inited {
|
||||
return initError
|
||||
}
|
||||
if bc.requestWasNil {
|
||||
return requestNilError
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// BaseFormContext combines BaseContext and FormContext
|
||||
type BaseFormContext struct {
|
||||
BaseContext
|
||||
httpx.FormContext
|
||||
}
|
||||
3
internal/dis/component/server/templates/footer.html
Normal file
3
internal/dis/component/server/templates/footer.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<p>
|
||||
Generated <time format="{{ .GeneratedAt.Format "2006-01-02T15:04:05Z" }}">{{ .GeneratedAt.Format "2006-01-02T15:04:05Z07:00" }}</time> by <a href="https://github.com/FAU-CDI/wisski-distillery" target="_blank" rel="noopener noreferer">WissKI Distillery</a>. This site might use cookies for essential purposes, see also <a href="/legal/">Legal Notices</a>.
|
||||
</p>
|
||||
49
internal/dis/component/server/templates/menu.go
Normal file
49
internal/dis/component/server/templates/menu.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/mux"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
// buildMenu builds the manu for this request for all known components in this distillery.
|
||||
//
|
||||
// NOTE(twiesing): Don't name this method "Menu", as it will cause a stack overflow.
|
||||
func (tpl *Templating) buildMenu(r *http.Request) []component.MenuItem {
|
||||
|
||||
path := mux.NormalizePath(r.URL.Path)
|
||||
|
||||
// get the static menu items, and then return all the regular ones
|
||||
var items []component.MenuItem
|
||||
for _, m := range tpl.Dependencies.Menuable {
|
||||
items = append(items, m.Menu(r)...)
|
||||
}
|
||||
for i, item := range items {
|
||||
items[i].Active = string(item.Path) == path
|
||||
}
|
||||
slices.SortFunc(items, component.MenuItemSort)
|
||||
return items
|
||||
}
|
||||
|
||||
// Menu returns a list of menu items provided by routeables
|
||||
func (tpl *Templating) Menu(r *http.Request) []component.MenuItem {
|
||||
return tpl.menu.Get(func() []component.MenuItem {
|
||||
items := make([]component.MenuItem, 0, len(tpl.Dependencies.Routeables))
|
||||
for _, route := range tpl.Dependencies.Routeables {
|
||||
routes := route.Routes()
|
||||
if routes.MenuTitle == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, component.MenuItem{
|
||||
Title: routes.MenuTitle,
|
||||
Priority: routes.MenuPriority,
|
||||
Path: template.URL(routes.Prefix),
|
||||
})
|
||||
}
|
||||
slices.SortFunc(items, component.MenuItemSort)
|
||||
return items
|
||||
})
|
||||
}
|
||||
139
internal/dis/component/server/templates/new.go
Normal file
139
internal/dis/component/server/templates/new.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/assets"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
)
|
||||
|
||||
// Parsed represents a parsed template that receives an underlying context of type C
|
||||
type Parsed[C any] struct {
|
||||
template *template.Template
|
||||
}
|
||||
|
||||
// Parse creates a new Parsed from a template source.
|
||||
// Parse calls panic() when parsing fails.
|
||||
func Parse[C any](name string, source []byte, Assets assets.Assets) Parsed[C] {
|
||||
return Parsed[C]{
|
||||
template: Assets.MustParseShared(name, string(source)),
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare prepares this template for use inside a concrete handler.
|
||||
// gaps must either be of length 0 or length 1 and may pre-fill gaps to be used when executing the template later.
|
||||
func (p *Parsed[C]) Prepare(tpl *Templating, gaps ...BaseContextGaps) *Template[C] {
|
||||
wrap := Template[C]{
|
||||
tpl: tpl,
|
||||
template: tpl.Template(p.template),
|
||||
}
|
||||
if len(gaps) > 1 {
|
||||
panic("WrapTemplate: must provide either 1 or no gaps")
|
||||
}
|
||||
if len(gaps) == 1 {
|
||||
wrap.gaps = gaps[0]
|
||||
}
|
||||
return &wrap
|
||||
}
|
||||
|
||||
// Tempalte represents an executable template.
|
||||
type Template[C any] struct {
|
||||
tpl *Templating
|
||||
template *template.Template
|
||||
gaps BaseContextGaps
|
||||
}
|
||||
|
||||
// Template returns a template that, if executed together with the context by the Context method, produces the desired result.
|
||||
func (tw *Template[C]) Template() *template.Template {
|
||||
return tw.template
|
||||
}
|
||||
|
||||
// Context generates a context for a given request that can be used to execute the provided template.
|
||||
func (tw *Template[C]) Context(r *http.Request, c C, gaps ...BaseContextGaps) any {
|
||||
// make the gaps something
|
||||
if len(gaps) > 1 {
|
||||
panic("Context: must provide either 1 or no gaps")
|
||||
}
|
||||
|
||||
// update the context with gaps
|
||||
{
|
||||
g := tw.gaps
|
||||
if len(gaps) == 1 {
|
||||
g = gaps[0]
|
||||
}
|
||||
tw.tpl.update(&c, r, g)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// ParseForm is like Parse[BaseFormContext]
|
||||
var ParseForm = Parse[BaseFormContext]
|
||||
|
||||
// FormTemplateContext returns a new handler for a form with the given base context
|
||||
func FormTemplateContext(tw *Template[BaseFormContext]) func(ctx httpx.FormContext, r *http.Request) any {
|
||||
return func(ctx httpx.FormContext, r *http.Request) any {
|
||||
return tw.Context(r, BaseFormContext{FormContext: ctx})
|
||||
}
|
||||
}
|
||||
|
||||
// MappedHandler returns a new handler that maps the incoming context via f
|
||||
func MappedHandler[In, Out any](tw *Template[Out], f func(ctx In, r *http.Request) (Out, BaseContextGaps)) func(ctx In, r *http.Request) any {
|
||||
// TODO: Should this one be removed?
|
||||
return func(ctx In, r *http.Request) any {
|
||||
c, g := f(ctx, r)
|
||||
return tw.Context(r, c, g)
|
||||
}
|
||||
}
|
||||
|
||||
// Hander returns a function that returns a context for the given template
|
||||
func (tw *Template[C]) Handler(f func(r *http.Request) (C, error)) func(r *http.Request) (any, error) {
|
||||
// TODO: Should this one be removed?
|
||||
return tw.HandlerWithGaps(func(r *http.Request, gaps *BaseContextGaps) (C, error) {
|
||||
return f(r)
|
||||
})
|
||||
}
|
||||
|
||||
// HTMLHandler returns a new HTMLHandler for this request
|
||||
func (tw *Template[C]) HTMLHandler(f func(r *http.Request) (C, error)) httpx.HTMLHandler[any] {
|
||||
return httpx.HTMLHandler[any]{
|
||||
Handler: tw.Handler(f),
|
||||
Template: tw.Template(),
|
||||
}
|
||||
}
|
||||
|
||||
// HandlerWithGaps works like handler, but additionally receives a gaps object to update.
|
||||
func (tw *Template[C]) HandlerWithGaps(f func(r *http.Request, gaps *BaseContextGaps) (C, error)) func(r *http.Request) (any, error) {
|
||||
// TODO: Drop this variant?
|
||||
var zero C
|
||||
return func(r *http.Request) (any, error) {
|
||||
g := tw.gaps.clone()
|
||||
c, err := f(r, &g)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
|
||||
// update the context
|
||||
return tw.Context(r, c, g), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (tw *Template[C]) HTMLHandlerWithGaps(f func(r *http.Request, gaps *BaseContextGaps) (C, error)) httpx.HTMLHandler[any] {
|
||||
return httpx.HTMLHandler[any]{
|
||||
Handler: tw.HandlerWithGaps(f),
|
||||
Template: tw.Template(),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute executes this template with the given context
|
||||
func (tw *Template[C]) Execute(w http.ResponseWriter, r *http.Request, c C, gaps ...BaseContextGaps) error {
|
||||
return tw.ExecuteWithError(w, r, c, nil, gaps...)
|
||||
}
|
||||
|
||||
// ExecuteWithError executes this template, or the default error handler if err != nil
|
||||
func (tw *Template[C]) ExecuteWithError(w http.ResponseWriter, r *http.Request, c C, err error, gaps ...BaseContextGaps) error {
|
||||
// TODO: Drop this variant?
|
||||
// TODO: This should be removed!
|
||||
return httpx.WriteHTML(tw.Context(r, c, gaps...), err, tw.template, "", w, r)
|
||||
}
|
||||
65
internal/dis/component/server/templates/template.go
Normal file
65
internal/dis/component/server/templates/template.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"text/template/parse"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/environment"
|
||||
)
|
||||
|
||||
const (
|
||||
footerName = "@custom/footer"
|
||||
aboutName = "@custom/about"
|
||||
)
|
||||
|
||||
//go:embed "footer.html"
|
||||
var footerTemplateStr string
|
||||
var defaultFooterTemplate = template.Must(template.New("footer.html").Parse(footerTemplateStr))
|
||||
|
||||
// Template creates a copy of template with shared template parts updated accordingly.
|
||||
// Any template using this should use one of the template contexts in this package.
|
||||
func (tpl *Templating) Template(t *template.Template) *template.Template {
|
||||
// TODO: This should not be used!
|
||||
|
||||
// create a clone of the template
|
||||
clone := template.Must(t.Clone())
|
||||
|
||||
// add all the fixed parse trees
|
||||
footerTree := tpl.getTemplateAsset(defaultFooterTemplate)
|
||||
template.Must(clone.AddParseTree(footerName, footerTree))
|
||||
|
||||
// optionally add the about asset
|
||||
if aboutTree := tpl.readTemplateAsset("about.html"); clone.Lookup(aboutName) != nil && aboutTree != nil {
|
||||
template.Must(clone.AddParseTree(aboutName, aboutTree))
|
||||
}
|
||||
return clone // and return the tree
|
||||
}
|
||||
|
||||
// getTemplateAsset returns an overridable template asset.
|
||||
//
|
||||
// If the asset named can successfully be parsed, it is returned.
|
||||
// If it can not be parsed, the default template is returned.
|
||||
func (tpl *Templating) getTemplateAsset(dflt *template.Template) *parse.Tree {
|
||||
tree := tpl.readTemplateAsset(dflt.Name())
|
||||
if tree == nil {
|
||||
return dflt.Tree.Copy()
|
||||
}
|
||||
return tree
|
||||
}
|
||||
|
||||
// readTemplateAsset is like getTemplateAssets, but takes an explicit name to read.
|
||||
// when the asset does not exist, or cannot be opened, returns nil.
|
||||
func (tpl *Templating) readTemplateAsset(name string) *parse.Tree {
|
||||
template, err := (func() (*template.Template, error) {
|
||||
data, err := environment.ReadFile(tpl.Environment, tpl.CustomAssetPath(name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return template.New(name).Parse(string(data))
|
||||
})()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return template.Tree
|
||||
}
|
||||
21
internal/dis/component/server/templates/templating.go
Normal file
21
internal/dis/component/server/templates/templating.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package templates
|
||||
|
||||
import (
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/lazy"
|
||||
)
|
||||
|
||||
// Templating implements templating customization
|
||||
type Templating struct {
|
||||
component.Base
|
||||
Dependencies struct {
|
||||
Routeables []component.Routeable
|
||||
Menuable []component.Menuable
|
||||
}
|
||||
menu lazy.Lazy[[]component.MenuItem]
|
||||
}
|
||||
|
||||
var (
|
||||
_ component.Backupable = (*Templating)(nil)
|
||||
_ component.Menuable = (*Templating)(nil)
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue