Refactor server and templates package

This commit is contained in:
Tom Wiesing 2023-01-19 13:22:48 +01:00
parent b6bf0a8900
commit 6ede99d7c6
No known key found for this signature in database
105 changed files with 341 additions and 339 deletions

View 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
})
}

View 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
})
}

View 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
}

View file

@ -0,0 +1,6 @@
{{ template "_base.html" . }}
{{ define "title" }}Components{{ end }}
{{ define "content" }}
{{ template "_anal.html" .Analytics }}
{{ end }}

View 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 }}

View 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 }}&nbsp;<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 }}

View file

@ -0,0 +1,6 @@
{{ template "_base.html" . }}
{{ define "title" }}{{ .Instance.Slug }} - Ingredients{{ end }}
{{ define "content" }}
{{ template "_anal.html" .Analytics }}
{{ end }}

View 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 &amp; 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 }}

View file

@ -0,0 +1,4 @@
{{ template "_form.html" . }}
{{ define "form/title" }}Create User{{ end }}
{{ define "form/button" }}Create{{ end }}

View 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"> &nbsp;
<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 }}

View 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
})
}

View 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
})
}

View 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)
}
}

View 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)
})
}