diff --git a/middleware/admin_or_user.go b/middleware/admin_or_user.go new file mode 100644 index 0000000..7fc386a --- /dev/null +++ b/middleware/admin_or_user.go @@ -0,0 +1,24 @@ +package middleware + +import ( + "github.com/Theodor-Springmann-Stiftung/musenalm/templating" + "github.com/pocketbase/pocketbase/core" +) + +// INFO: Here the URL must have a path value "uid" which is the user ID of the affected user. +func IsAdminOrUser() func(*core.RequestEvent) error { + return func(e *core.RequestEvent) error { + req := templating.NewRequest(e) + user := req.User() + if user == nil { + return e.Error(401, "Unauthorized", nil) + } + + uid := e.Request.PathValue("uid") + if uid != user.Id && user.Role != "Admin" { + return e.Error(403, "Forbidden", nil) + } + + return e.Next() + } +} diff --git a/pages/user_edit.go b/pages/user_edit.go index 0a52261..4e91e1b 100644 --- a/pages/user_edit.go +++ b/pages/user_edit.go @@ -14,9 +14,13 @@ import ( ) const ( - URL_USER_EDIT = "/user/{uid}/edit/" - UID_PATH_VALUE = "uid" - TEMPLATE_USER_EDIT = "/user/edit/" + URL_USER = "/user/{uid}/" + URL_USER_EDIT = "edit/" + URL_USER_LOGOUT = "logout/" + URL_USER_DEACTIVATE = "deactivate/" + URL_USER_ACTIVATE = "activate/" + UID_PATH_VALUE = "uid" + TEMPLATE_USER_EDIT = "/user/edit/" ) func init() { @@ -36,8 +40,12 @@ type UserEditPage struct { } func (p *UserEditPage) Setup(router *router.Router[*core.RequestEvent], app core.App, engine *templating.Engine) error { - router.GET(URL_USER_EDIT, p.GET(engine, app)) - router.POST(URL_USER_EDIT, p.POST(engine, app)) + rg := router.Group(URL_USER) + rg.BindFunc(middleware.IsAdminOrUser()) + rg.GET(URL_USER_EDIT, p.GET(engine, app)) + rg.POST(URL_USER_EDIT, p.POST(engine, app)) + rg.POST(URL_USER_DEACTIVATE, p.POSTDeactivate(engine, app)) + rg.POST(URL_USER_ACTIVATE, p.POSTActivate(engine, app)) return nil } @@ -46,16 +54,6 @@ func (p *UserEditPage) GET(engine *templating.Engine, app core.App) HandleFunc { data := make(map[string]any) uid := e.Request.PathValue(UID_PATH_VALUE) - req := templating.NewRequest(e) - user := req.User() - - if user == nil { - return engine.Response404(e, nil, nil) - } - - if user.Id != uid && user.Role != "Admin" { - return engine.Response404(e, nil, nil) - } u, err := app.FindRecordById(dbmodels.USERS_TABLE, uid) if err != nil { @@ -105,7 +103,7 @@ func DeleteSessionsForUser(app core.App, uid string) error { return nil } -func InvalidDataResponse(engine *templating.Engine, e *core.RequestEvent, error string, user *dbmodels.FixedUser) error { +func (p *UserEditPage) InvalidDataResponse(engine *templating.Engine, e *core.RequestEvent, error string, user *dbmodels.FixedUser) error { data := make(map[string]any) data["error"] = error data["user"] = user @@ -118,9 +116,7 @@ func InvalidDataResponse(engine *templating.Engine, e *core.RequestEvent, error data["csrf_nonce"] = nonce data["csrf_token"] = token - SetRedirect(data, e) - - str, err := engine.RenderToString(e, data, TEMPLATE_USER_EDIT, "blank") + str, err := engine.RenderToString(e, data, p.Template, p.Layout) if err != nil { return engine.Response500(e, err, data) } @@ -128,6 +124,98 @@ func InvalidDataResponse(engine *templating.Engine, e *core.RequestEvent, error return e.HTML(400, str) } +func (p *UserEditPage) POSTDeactivate(engine *templating.Engine, app core.App) HandleFunc { + return func(e *core.RequestEvent) error { + uid := e.Request.PathValue(UID_PATH_VALUE) + req := templating.NewRequest(e) + user := req.User() + + if user == nil { + return engine.Response404(e, nil, nil) + } + + if user.Id != uid && user.Role != "Admin" { + return engine.Response404(e, nil, nil) + } + + u, err := app.FindRecordById(dbmodels.USERS_TABLE, uid) + if err != nil { + return engine.Response404(e, err, nil) + } + + user_proxy := dbmodels.NewUser(u) + user_proxy.SetDeactivated(true) + + if err := app.Save(user_proxy); err != nil { + return engine.Response500(e, err, nil) + } + + go middleware.SESSION_CACHE.DeleteSessionByUserID(user_proxy.Id) + + if user_proxy.Id == user.Id { + // INFO: user deactivated his own account, so we log him out + return e.Redirect(303, "/login/") + } + + data := make(map[string]any) + uf := user_proxy.Fixed() + data["user"] = &uf + data["success"] = "Benutzer " + uf.Name + " deaktiviert." + + nonce, token, err := CSRF_CACHE.GenerateTokenBundle() + if err != nil { + return engine.Response500(e, err, data) + } + + data["csrf_token"] = token + data["csrf_nonce"] = nonce + + return engine.Response200(e, TEMPLATE_USER_EDIT, data, p.Layout) + } +} + +func (p *UserEditPage) POSTActivate(engine *templating.Engine, app core.App) HandleFunc { + return func(e *core.RequestEvent) error { + uid := e.Request.PathValue(UID_PATH_VALUE) + req := templating.NewRequest(e) + user := req.User() + + u, err := app.FindRecordById(dbmodels.USERS_TABLE, uid) + if err != nil { + return engine.Response404(e, err, nil) + } + + user_proxy := dbmodels.NewUser(u) + user_proxy.SetDeactivated(false) + + if err := app.Save(user_proxy); err != nil { + return engine.Response500(e, err, nil) + } + + go middleware.SESSION_CACHE.DeleteSessionByUserID(user_proxy.Id) + + if user_proxy.Id == user.Id { + // INFO: user deactivated his own account, so we log him out + return e.Redirect(303, "/login/") + } + + data := make(map[string]any) + uf := user_proxy.Fixed() + data["user"] = &uf + data["success"] = "Benutzer " + uf.Name + " aktiviert." + + nonce, token, err := CSRF_CACHE.GenerateTokenBundle() + if err != nil { + return engine.Response500(e, err, data) + } + + data["csrf_token"] = token + data["csrf_nonce"] = nonce + + return engine.Response200(e, TEMPLATE_USER_EDIT, data, p.Layout) + } +} + func (p *UserEditPage) POST(engine *templating.Engine, app core.App) HandleFunc { return func(e *core.RequestEvent) error { data := make(map[string]any) @@ -164,19 +252,19 @@ func (p *UserEditPage) POST(engine *templating.Engine, app core.App) HandleFunc }{} if err := e.BindBody(&formdata); err != nil { - return InvalidDataResponse(engine, e, err.Error(), &fu) + return p.InvalidDataResponse(engine, e, err.Error(), &fu) } if formdata.CsrfNonce != "" && formdata.CsrfToken != "" { if _, err := CSRF_CACHE.ValidateTokenBundle(formdata.CsrfNonce, formdata.CsrfToken); err != nil { - return InvalidDataResponse(engine, e, "CSRF ungültig oder abgelaufen", &fu) + return p.InvalidDataResponse(engine, e, "CSRF ungültig oder abgelaufen", &fu) } } else { - return InvalidDataResponse(engine, e, "CSRF ungültig oder abgelaufen", &fu) + return p.InvalidDataResponse(engine, e, "CSRF ungültig oder abgelaufen", &fu) } if formdata.Email == "" || formdata.Name == "" { - return InvalidDataResponse(engine, e, "Bitte alle Felder ausfüllen", &fu) + return p.InvalidDataResponse(engine, e, "Bitte alle Felder ausfüllen", &fu) } // INFO: at this point email and name changes are allowed @@ -190,20 +278,20 @@ func (p *UserEditPage) POST(engine *templating.Engine, app core.App) HandleFunc user_proxy.SetRole(formdata.Role) rolechanged = true } else { - return InvalidDataResponse(engine, e, "Rolle nicht erlaubt", &fu) + return p.InvalidDataResponse(engine, e, "Rolle nicht erlaubt", &fu) } } passwordchanged := false if formdata.Password != "" || formdata.PasswordRepeat != "" || formdata.OldPassword != "" { if user.Role != "Admin" && formdata.OldPassword == "" { - return InvalidDataResponse(engine, e, "Altes Passwort erforderlich", &fu) + return p.InvalidDataResponse(engine, e, "Altes Passwort erforderlich", &fu) } else if user.Role != "Admin" && !user_proxy.ValidatePassword(formdata.OldPassword) { - return InvalidDataResponse(engine, e, "Altes Passwort falsch", &fu) + return p.InvalidDataResponse(engine, e, "Altes Passwort falsch", &fu) } if formdata.Password != formdata.PasswordRepeat { - return InvalidDataResponse(engine, e, "Passwörter stimmen nicht überein", &fu) + return p.InvalidDataResponse(engine, e, "Passwörter stimmen nicht überein", &fu) } user_proxy.SetPassword(formdata.Password) @@ -211,14 +299,14 @@ func (p *UserEditPage) POST(engine *templating.Engine, app core.App) HandleFunc } if err := app.Save(user_proxy); err != nil { - return InvalidDataResponse(engine, e, err.Error(), &fu) + return p.InvalidDataResponse(engine, e, err.Error(), &fu) } slog.Info("UserEditPage: User edited", "user_id", user_proxy.Id, "role_changed", rolechanged, "password_changed", passwordchanged, "formdata", formdata) if rolechanged || (passwordchanged && formdata.Logout == "on") { slog.Error("UserEditPage: Deleting sessions for user", "user_id", user_proxy.Id, "role_changed", rolechanged, "password_changed", passwordchanged) if err := DeleteSessionsForUser(app, user_proxy.Id); err != nil { - return InvalidDataResponse(engine, e, "Fehler beim Löschen der Sitzungen: "+err.Error(), &fu) + return p.InvalidDataResponse(engine, e, "Fehler beim Löschen der Sitzungen: "+err.Error(), &fu) } if user_proxy.Id == user.Id { @@ -244,7 +332,6 @@ func (p *UserEditPage) POST(engine *templating.Engine, app core.App) HandleFunc data["csrf_token"] = token data["csrf_nonce"] = nonce - SetRedirect(data, e) return engine.Response200(e, TEMPLATE_USER_EDIT, data, p.Layout) } } diff --git a/pages/user_management.go b/pages/user_management.go index ae53988..7be5e3d 100644 --- a/pages/user_management.go +++ b/pages/user_management.go @@ -15,6 +15,9 @@ import ( const ( URL_USER_MANAGEMENT = "/user/management" TEMPLATE_USER_MANAGEMENT = "/user/management/" + URL_DEACTIVATE_USER = "/deactivate/" + URL_ACTIVATE_USER = "/activate/" + URL_LOGOUT_USER = "/logout/" ) type SessionCount struct { @@ -42,7 +45,9 @@ func (p *UserManagementPage) Setup(router *router.Router[*core.RequestEvent], ap rg := router.Group(URL_USER_MANAGEMENT) rg.BindFunc(middleware.IsAdmin()) rg.GET("", p.GET(engine, app)) - rg.POST("", p.POST(engine, app)) + rg.POST(URL_DEACTIVATE_USER, p.POSTDeactivate(engine, app)) + rg.POST(URL_ACTIVATE_USER, p.POSTActivate(engine, app)) + rg.POST(URL_LOGOUT_USER, p.POSTLogout(engine, app)) return nil } @@ -63,47 +68,125 @@ func GetSessionsCounts(app core.App) ([]*SessionCount, error) { func (p *UserManagementPage) GET(engine *templating.Engine, app core.App) HandleFunc { return func(e *core.RequestEvent) error { - records := []*core.Record{} - err := app.RecordQuery(dbmodels.USERS_TABLE).OrderBy(dbmodels.USERS_NAME_FIELD).All(&records) - if err != nil { - return engine.Response500(e, err, nil) - } - - users := make([]*dbmodels.User, 0, len(records)) - for _, record := range records { - users = append(users, dbmodels.NewUser(record)) - } - - sessionCounts, err := GetSessionsCounts(app) - if err != nil { - return engine.Response500(e, err, nil) - } - - scmap := make(map[string]int) - for _, sc := range sessionCounts { - scmap[sc.UserId] = sc.Count - } - data := make(map[string]any) - data["users"] = users - data["len"] = len(users) - data["session_counts"] = scmap - - nonce, token, err := CSRF_CACHE.GenerateTokenBundle() - if err != nil { - return engine.Response500(e, err, data) - } - data["csrf_nonce"] = nonce - data["csrf_token"] = token - + p.getData(app, data) SetRedirect(data, e) return engine.Response200(e, p.Template, data, p.Layout) } } -func (p *UserManagementPage) POST(engine *templating.Engine, app core.App) HandleFunc { +func (p *UserManagementPage) getData(app core.App, data map[string]any) error { + records := []*core.Record{} + err := app.RecordQuery(dbmodels.USERS_TABLE).OrderBy(dbmodels.USERS_NAME_FIELD).All(&records) + if err != nil { + return fmt.Errorf("Konnte keine Nutzer laden: %w", err) + } + + users := make([]*dbmodels.User, 0, len(records)) + for _, record := range records { + users = append(users, dbmodels.NewUser(record)) + } + + sessionCounts, err := GetSessionsCounts(app) + if err != nil { + return fmt.Errorf("Konnte keine Sitzungsanzahlen laden: %w", err) + } + + scmap := make(map[string]int) + for _, sc := range sessionCounts { + scmap[sc.UserId] = sc.Count + } + + data["users"] = users + data["len"] = len(users) + data["session_counts"] = scmap + + csrfNonce, csrfToken, err := CSRF_CACHE.GenerateTokenBundle() + if err != nil { + return fmt.Errorf("Konnte kein CSRF-Token generieren", err) + } + data["csrf_nonce"] = csrfNonce + data["csrf_token"] = csrfToken + + return nil +} + +func (p *UserManagementPage) ErrorResponse(engine *templating.Engine, e *core.RequestEvent, err error) error { + data := make(map[string]any) + data["error"] = err.Error() + + nonce, token, err := CSRF_CACHE.GenerateTokenBundle() + if err != nil { + return engine.Response500(e, err, data) + } + data["csrf_nonce"] = nonce + data["csrf_token"] = token + + SetRedirect(data, e) + + str, err := engine.RenderToString(e, data, p.Template, p.Layout) + + return e.HTML(400, str) +} + +func (p *UserManagementPage) POSTDeactivate(engine *templating.Engine, app core.App) HandleFunc { + return p.UserAction(engine, app, func(user *dbmodels.User) { + user.SetDeactivated(true) + }) +} + +func (p *UserManagementPage) POSTActivate(engine *templating.Engine, app core.App) HandleFunc { + return p.UserAction(engine, app, func(user *dbmodels.User) { + user.SetDeactivated(false) + }) +} + +func (p *UserManagementPage) POSTLogout(engine *templating.Engine, app core.App) HandleFunc { + return p.UserAction(engine, app, func(user *dbmodels.User) {}) +} + +func (p *UserManagementPage) UserAction(engine *templating.Engine, app core.App, fn func(user *dbmodels.User)) HandleFunc { return func(e *core.RequestEvent) error { - return fmt.Errorf("not implemented") + formdata := struct { + User string `form:"uid"` + CSRF string `form:"csrf_token"` + Nonce string `form:"csrf_nonce"` + }{} + + if err := e.BindBody(&formdata); err != nil { + return p.ErrorResponse(engine, e, fmt.Errorf("Konnte Formular nicht binden: %w", err)) + } + + if _, err := CSRF_CACHE.ValidateTokenBundle(formdata.Nonce, formdata.CSRF); err != nil { + return p.ErrorResponse(engine, e, err) + } + + user, err := app.FindRecordById(dbmodels.USERS_TABLE, formdata.User) + if err != nil { + return p.ErrorResponse(engine, e, fmt.Errorf("Konnte Nutzer nicht finden.")) + } + + u := dbmodels.NewUser(user) + + fn(u) + + if err := app.Save(u); err != nil { + return p.ErrorResponse(engine, e, fmt.Errorf("Konnte Nutzer nicht deaktivieren: %w", err)) + } + + go DeleteSessionsForUser(app, u.Id) + + data := make(map[string]any) + data["success"] = "Nutzer " + u.Name() + "(" + u.Email() + ") wurde deaktiviert." + + p.getData(app, data) + + req := templating.NewRequest(e) + if req.User() != nil && req.User().Id == u.Id { + return e.Redirect(303, "/login/") + } + + return engine.Response200(e, p.Template, data, p.Layout) } } diff --git a/templating/templates.go b/templating/templates.go new file mode 100644 index 0000000..5ca91ff --- /dev/null +++ b/templating/templates.go @@ -0,0 +1,103 @@ +package templating + +import ( + "io/fs" + "log/slog" + "path/filepath" + "slices" + "strings" + "sync" +) + +// TODO: redo template parsing to allow for just rendering single components +type File struct { + Name string + Path string + Ext string +} + +func FromDirEntry(entry fs.DirEntry, path string) File { + ext := filepath.Ext(entry.Name()) + Name := strings.TrimSuffix(entry.Name(), ext) + return File{ + Name: Name, + Ext: ext, + Path: path, + } +} + +type Directory struct { + Path string + Locals map[string]File + Globals map[string]File +} + +type Templates struct { + Directories sync.Map // map[string]*Directory +} + +func (t *Templates) NewDirectory(path string, fsys fs.FS) (*Directory, error) { + fspath := PathToFSPath(path) + entries, err := fs.ReadDir(fsys, fspath) + + wg := &sync.WaitGroup{} + + if err != nil { + return nil, NewError(FileAccessError, fspath) + } + + dir := &Directory{} + for _, file := range entries { + if file.IsDir() && file.Name() == TEMPLATE_COMPONENT_DIRECTORY { + wg.Add(1) + go func() { + defer wg.Done() + locals, globals, err := t.ScanComponentDirectory(filepath.Join(fspath, file.Name()), fsys) + if err != nil { + slog.Error("Failed to scan component directory", "path", file.Name(), "error", err) + return + } + dir.Locals = locals + dir.Globals = globals + }() + } + + if !slices.Contains(TEMPLATE_FORMATS, filepath.Ext(file.Name())) { + continue + } + + } + + wg.Wait() + return dir, nil +} + +func (t *Templates) ScanComponentDirectory(path string, fsys fs.FS) (map[string]File, map[string]File, error) { + locals := make(map[string]File) + globals := make(map[string]File) + fspath := PathToFSPath(path) + err := fs.WalkDir(fsys, fspath, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return NewError(FileAccessError, path) + } + + if d.IsDir() || !slices.Contains(TEMPLATE_FORMATS, filepath.Ext(d.Name())) { + return nil + } + + f := FromDirEntry(d, path) + if strings.HasPrefix(f.Name, TEMPLATE_GLOBAL_PREFIX) { + globals[f.Name] = f + } else { + locals[f.Name] = f + } + + return nil + }) + + if err != nil { + return nil, nil, NewError(FileAccessError, path) + } + + return locals, globals, nil +} diff --git a/views/routes/components/_usermessage.gohtml b/views/routes/components/_usermessage.gohtml new file mode 100644 index 0000000..2f7679c --- /dev/null +++ b/views/routes/components/_usermessage.gohtml @@ -0,0 +1,19 @@ +{{ $model := . }} + + +
+ {{ if $model.success }} +
+ {{ $model.success }} +
+ {{ end }} + {{ if $model.error }} +
+ {{ $model.error }} +
+ {{ end }} +
diff --git a/views/routes/messaging/user_message/body.gohtml b/views/routes/messaging/user_message/body.gohtml new file mode 100644 index 0000000..df7c3d8 --- /dev/null +++ b/views/routes/messaging/user_message/body.gohtml @@ -0,0 +1,17 @@ +{{ $model := . }} +
+ {{ if $model.success }} +
+ {{ $model.success }} +
+ {{ end }} + {{ if $model.error }} +
+ {{ $model.error }} +
+ {{ end }} +
diff --git a/views/routes/user/edit/body.gohtml b/views/routes/user/edit/body.gohtml index 6ea15dc..4b7adb3 100644 --- a/views/routes/user/edit/body.gohtml +++ b/views/routes/user/edit/body.gohtml @@ -17,20 +17,7 @@
- {{ if $model.success }} -
- {{ $model.success }} -
- {{ end }} - {{ if $model.error }} -
- {{ $model.error }} -
- {{ end }} + {{ template "_usermessage" $model }}

- Achtung! Wenn Sie die Rolle eines Benutzers ändern, wird dieser von allen laufenden - Sitzungen abgemeldet und muss sich erneut anmelden. + Achtung! Wenn Sie die Rolle eines Benutzers ändern, wird dieser unter Umständen von + laufenden Sitzungen abgemeldet und muss sich erneut anmelden.

{{- end -}} diff --git a/views/routes/user/management/body.gohtml b/views/routes/user/management/body.gohtml index d620391..519716a 100644 --- a/views/routes/user/management/body.gohtml +++ b/views/routes/user/management/body.gohtml @@ -21,20 +21,22 @@
- {{ if $model.success }} -
+ {{ if $model.success }} +
- {{ $model.success }} -
- {{ end }} - {{ if $model.error }} -
- {{ $model.error }} -
- {{ end }} + {{ $model.error }} +
+ {{ end }} +
@@ -54,6 +56,7 @@
{{ index $model.session_counts $u.Id }} + {{- if $u.Deactivated }} {{- else -}}