Files
warpbox/lib/server/account_user_edit.go

558 lines
15 KiB
Go

package server
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
type UserEditView struct {
PageTitle string
WindowTitle string
WindowIcon string
PageScripts []string
AccountNav AccountNavView
CSRFToken string
Target metastore.User
Tags []metastore.Tag
AdminTagID string
IsAdmin bool
IsPending bool
Status string
Perms metastore.EffectivePermissions
PolicyJSON string
CanManage bool
IsSelf bool
Error string
Success string
// precomputed display values
TagNames string
CreatedAtStr string
UpdatedAtStr string
MaxFileSizeStr string
MaxBoxSizeStr string
MaxExpiryStr string
// precomputed perm override checkbox states
Check map[string]bool
}
func (app *App) handleAccountUserEdit(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
perms := currentAccountPermissions(ctx)
if !perms.AdminUsersView && !perms.AdminUsersManage {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
userID := strings.TrimSpace(ctx.Param("id"))
view, err := app.buildUserEditView(ctx, actor, userID, perms.AdminUsersManage, "", "")
if err != nil {
ctx.String(http.StatusNotFound, "User not found")
return
}
ctx.HTML(http.StatusOK, "account_user_edit.html", view)
}
func (app *App) handleAccountUserEditPost(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
perms := currentAccountPermissions(ctx)
if !perms.AdminUsersManage {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
userID := strings.TrimSpace(ctx.Param("id"))
target, found, err := app.store.GetUser(userID)
if err != nil || !found {
redirectUserEdit(ctx, userID, "User not found.", "")
return
}
// profile
username := strings.TrimSpace(ctx.PostForm("username"))
email := strings.TrimSpace(ctx.PostForm("email"))
adminNote := strings.TrimSpace(ctx.PostForm("admin_note"))
if username == "" {
redirectUserEdit(ctx, userID, "Username is required.", "")
return
}
// state (cannot change pending via this field)
isPending := strings.HasPrefix(target.PasswordHash, "invite/")
if !isPending {
stateVal := ctx.PostForm("state")
switch stateVal {
case "disabled":
if target.ID == actor.ID {
redirectUserEdit(ctx, userID, "You cannot disable yourself.", "")
return
}
if !target.Disabled {
if err := app.checkLastAdminDisable([]string{target.ID}); err != nil {
redirectUserEdit(ctx, userID, err.Error(), "")
return
}
}
target.Disabled = true
case "active":
target.Disabled = false
}
}
// admin tag toggle
adminTag, adminTagOK, err := app.store.GetTagByName(metastore.AdminTagName)
if err != nil {
redirectUserEdit(ctx, userID, "Could not verify admin tag.", "")
return
}
wantsAdmin := ctx.PostForm("is_admin") == "1"
if adminTagOK {
hasAdmin := containsString(target.TagIDs, adminTag.ID)
if wantsAdmin && !hasAdmin {
target.TagIDs = append(target.TagIDs, adminTag.ID)
} else if !wantsAdmin && hasAdmin {
if err := app.checkLastAdminDisable([]string{target.ID}); err != nil {
redirectUserEdit(ctx, userID, "Cannot remove admin from the last active administrator.", "")
return
}
target.TagIDs = removeString(target.TagIDs, adminTag.ID)
}
}
// per-user permission overrides
target.PermOverrides = &metastore.UserPermOverrides{
UploadAllowed: boolPtr(ctx.PostForm("upload_allowed") == "1"),
ManageOwnBoxes: boolPtr(ctx.PostForm("manage_own_boxes") == "1"),
ZipDownloadAllowed: boolPtr(ctx.PostForm("zip_download_allowed") == "1"),
OneTimeDownloadAllowed: boolPtr(ctx.PostForm("one_time_download_allowed") == "1"),
RenewableAllowed: boolPtr(ctx.PostForm("renewable_allowed") == "1"),
AllowPasswordProtected: boolPtr(ctx.PostForm("allow_password_protected") == "1"),
RenewOnAccess: boolPtr(ctx.PostForm("renew_on_access") == "1"),
RenewOnDownload: boolPtr(ctx.PostForm("renew_on_download") == "1"),
AllowOwnerBoxEditing: boolPtr(ctx.PostForm("allow_owner_box_editing") == "1"),
}
// limits
if raw := ctx.PostForm("max_file_size_bytes"); raw != "" {
v, err := parseOptionalInt64(raw)
if err != nil {
redirectUserEdit(ctx, userID, "Max file size: "+err.Error(), "")
return
}
target.MaxFileSizeBytes = v
} else {
target.MaxFileSizeBytes = nil
}
if raw := ctx.PostForm("max_box_size_bytes"); raw != "" {
v, err := parseOptionalInt64(raw)
if err != nil {
redirectUserEdit(ctx, userID, "Max box size: "+err.Error(), "")
return
}
target.MaxBoxSizeBytes = v
} else {
target.MaxBoxSizeBytes = nil
}
if raw := ctx.PostForm("max_expiry_seconds"); raw != "" {
v, err := parseOptionalInt64(raw)
if err != nil {
redirectUserEdit(ctx, userID, "Max expiry: "+err.Error(), "")
return
}
target.MaxExpirySeconds = v
} else {
target.MaxExpirySeconds = nil
}
target.Username = username
target.Email = email
target.AdminNote = adminNote
if err := app.store.UpdateUser(target); err != nil {
redirectUserEdit(ctx, userID, "Could not save user: "+err.Error(), "")
return
}
redirectUserEdit(ctx, userID, "", "User saved.")
}
func (app *App) handleAccountUserDisable(ctx *gin.Context) {
app.handleAccountUserSetDisabled(ctx, true)
}
func (app *App) handleAccountUserEnable(ctx *gin.Context) {
app.handleAccountUserSetDisabled(ctx, false)
}
func (app *App) handleAccountUserSetDisabled(ctx *gin.Context, disabled bool) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
perms := currentAccountPermissions(ctx)
if !perms.AdminUsersManage {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
userID := strings.TrimSpace(ctx.Param("id"))
if userID == actor.ID && disabled {
redirectUserEdit(ctx, userID, "You cannot disable yourself.", "")
return
}
if disabled {
if err := app.checkLastAdminDisable([]string{userID}); err != nil {
redirectUserEdit(ctx, userID, err.Error(), "")
return
}
}
target, found, err := app.store.GetUser(userID)
if err != nil || !found {
redirectUserEdit(ctx, userID, "User not found.", "")
return
}
target.Disabled = disabled
if err := app.store.UpdateUser(target); err != nil {
redirectUserEdit(ctx, userID, "Could not update user.", "")
return
}
action := "enabled"
if disabled {
action = "disabled"
}
redirectUserEdit(ctx, userID, "", "User "+action+".")
}
func (app *App) handleAccountUserPasswordReset(ctx *gin.Context) {
_, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
perms := currentAccountPermissions(ctx)
if !perms.AdminUsersManage {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
userID := strings.TrimSpace(ctx.Param("id"))
target, found, err := app.store.GetUser(userID)
if err != nil || !found {
redirectUserEdit(ctx, userID, "User not found.", "")
return
}
newPassword := randomPassword()
hash, err := metastore.HashPassword(newPassword)
if err != nil {
redirectUserEdit(ctx, userID, "Could not hash password.", "")
return
}
target.PasswordHash = hash
target.Disabled = false
if err := app.store.UpdateUser(target); err != nil {
redirectUserEdit(ctx, userID, "Could not reset password.", "")
return
}
redirectUserEdit(ctx, userID, "", "Password reset. Temporary password: "+newPassword)
}
func (app *App) handleAccountUserRevokeSessions(ctx *gin.Context) {
_, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
perms := currentAccountPermissions(ctx)
if !perms.AdminUsersManage {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
userID := strings.TrimSpace(ctx.Param("id"))
if err := app.store.RevokeUserSessions(userID); err != nil {
redirectUserEdit(ctx, userID, "Could not revoke sessions.", "")
return
}
redirectUserEdit(ctx, userID, "", "All sessions revoked.")
}
func (app *App) buildUserEditView(ctx *gin.Context, actor metastore.User, userID string, canManage bool, errMsg string, successMsg string) (UserEditView, error) {
target, found, err := app.store.GetUser(userID)
if err != nil || !found {
return UserEditView{}, err
}
tags, _ := app.store.ListTags()
adminTagID := ""
for _, t := range tags {
if t.Name == metastore.AdminTagName {
adminTagID = t.ID
break
}
}
isAdmin := containsString(target.TagIDs, adminTagID)
isPending := strings.HasPrefix(target.PasswordHash, "invite/")
status := "active"
if isPending {
status = "pending"
} else if target.Disabled {
status = "disabled"
}
effectivePerms, _ := app.permissionsForUser(target)
policyJSON := buildPolicyJSON(target.Username, status, effectivePerms, target.PermOverrides)
// tag names
tagNames := make([]string, 0, len(target.TagIDs))
for _, tagID := range target.TagIDs {
for _, t := range tags {
if t.ID == tagID {
tagNames = append(tagNames, t.Name)
break
}
}
}
// perm override checkboxes
checks := map[string]bool{
"upload_allowed": effectivePerms.UploadAllowed,
"manage_own_boxes": false,
"zip_download_allowed": effectivePerms.ZipDownloadAllowed,
"one_time_download_allowed": effectivePerms.OneTimeDownloadAllowed,
"renewable_allowed": effectivePerms.RenewableAllowed,
"allow_password_protected": false,
"renew_on_access": false,
"renew_on_download": false,
"allow_owner_box_editing": false,
}
if o := target.PermOverrides; o != nil {
if o.UploadAllowed != nil {
checks["upload_allowed"] = *o.UploadAllowed
}
if o.ManageOwnBoxes != nil {
checks["manage_own_boxes"] = *o.ManageOwnBoxes
}
if o.ZipDownloadAllowed != nil {
checks["zip_download_allowed"] = *o.ZipDownloadAllowed
}
if o.OneTimeDownloadAllowed != nil {
checks["one_time_download_allowed"] = *o.OneTimeDownloadAllowed
}
if o.RenewableAllowed != nil {
checks["renewable_allowed"] = *o.RenewableAllowed
}
if o.AllowPasswordProtected != nil {
checks["allow_password_protected"] = *o.AllowPasswordProtected
}
if o.RenewOnAccess != nil {
checks["renew_on_access"] = *o.RenewOnAccess
}
if o.RenewOnDownload != nil {
checks["renew_on_download"] = *o.RenewOnDownload
}
if o.AllowOwnerBoxEditing != nil {
checks["allow_owner_box_editing"] = *o.AllowOwnerBoxEditing
}
}
return UserEditView{
PageTitle: "Edit User — " + target.Username,
WindowTitle: "User Edit — " + target.Username,
WindowIcon: "U",
PageScripts: []string{"/static/js/account-user-edit.js"},
AccountNav: app.accountNavView(ctx, "users"),
CSRFToken: app.currentCSRFToken(ctx),
Target: target,
Tags: tags,
AdminTagID: adminTagID,
IsAdmin: isAdmin,
IsPending: isPending,
Status: status,
Perms: effectivePerms,
PolicyJSON: policyJSON,
CanManage: canManage,
IsSelf: actor.ID == target.ID,
Error: errMsg,
Success: successMsg,
TagNames: strings.Join(tagNames, ", "),
CreatedAtStr: formatAdminTime(target.CreatedAt),
UpdatedAtStr: formatAdminTime(target.UpdatedAt),
MaxFileSizeStr: int64PtrStr(target.MaxFileSizeBytes),
MaxBoxSizeStr: int64PtrStr(target.MaxBoxSizeBytes),
MaxExpiryStr: int64PtrStr(target.MaxExpirySeconds),
Check: checks,
}, nil
}
func buildPolicyJSON(username string, status string, perms metastore.EffectivePermissions, overrides *metastore.UserPermOverrides) string {
type permMap struct {
BoxesCreate bool `json:"boxes.create"`
ManageOwn bool `json:"boxes.manage_own"`
RefreshOwn bool `json:"boxes.refresh_own"`
DownloadsZip bool `json:"downloads.zip"`
DownloadsOneTime bool `json:"downloads.one_time"`
AdminAccess bool `json:"admin.access"`
AdminUsers bool `json:"admin.users.manage"`
AdminSettings bool `json:"admin.settings.manage"`
}
type limitsMap struct {
MaxFileSizeBytes int64 `json:"max_file_size_bytes"`
MaxBoxSizeBytes int64 `json:"max_box_size_bytes"`
MaxExpirySeconds int64 `json:"max_expiry_seconds"`
}
type overridesMap struct {
AllowPassword bool `json:"allow_password_protected"`
RenewOnAccess bool `json:"renew_on_access"`
RenewOnDownload bool `json:"renew_on_download"`
AllowOwnerEdit bool `json:"allow_owner_box_editing"`
}
type preview struct {
User string `json:"user"`
Status string `json:"status"`
Permissions permMap `json:"permissions"`
Limits limitsMap `json:"limits"`
Overrides overridesMap `json:"overrides"`
}
manageOwn := false
allowPwd := false
renewAccess := false
renewDownload := false
allowOwnerEdit := false
if overrides != nil {
if overrides.ManageOwnBoxes != nil {
manageOwn = *overrides.ManageOwnBoxes
}
if overrides.AllowPasswordProtected != nil {
allowPwd = *overrides.AllowPasswordProtected
}
if overrides.RenewOnAccess != nil {
renewAccess = *overrides.RenewOnAccess
}
if overrides.RenewOnDownload != nil {
renewDownload = *overrides.RenewOnDownload
}
if overrides.AllowOwnerBoxEditing != nil {
allowOwnerEdit = *overrides.AllowOwnerBoxEditing
}
}
p := preview{
User: username,
Status: status,
Permissions: permMap{
BoxesCreate: perms.UploadAllowed,
ManageOwn: manageOwn,
RefreshOwn: perms.RenewableAllowed,
DownloadsZip: perms.ZipDownloadAllowed,
DownloadsOneTime: perms.OneTimeDownloadAllowed,
AdminAccess: perms.AdminAccess,
AdminUsers: perms.AdminUsersManage,
AdminSettings: perms.AdminSettingsManage,
},
Limits: limitsMap{
MaxFileSizeBytes: perms.MaxFileSizeBytes,
MaxBoxSizeBytes: perms.MaxBoxSizeBytes,
MaxExpirySeconds: perms.MaxExpirySeconds,
},
Overrides: overridesMap{
AllowPassword: allowPwd,
RenewOnAccess: renewAccess,
RenewOnDownload: renewDownload,
AllowOwnerEdit: allowOwnerEdit,
},
}
data, err := json.MarshalIndent(p, "", " ")
if err != nil {
return "{}"
}
return string(data)
}
func (app *App) checkLastAdminDisable(ids []string) error {
adminTag, ok, err := app.store.GetTagByName(metastore.AdminTagName)
if err != nil || !ok {
return nil
}
adminCount, err := app.store.CountAdminUsers(adminTag.ID)
if err != nil {
return err
}
removing := 0
for _, id := range ids {
u, found, _ := app.store.GetUser(id)
if found && !u.Disabled && containsString(u.TagIDs, adminTag.ID) {
removing++
}
}
if adminCount-removing < 1 {
return fmt.Errorf("cannot remove the last active administrator")
}
return nil
}
func int64PtrStr(v *int64) string {
if v == nil {
return ""
}
return fmt.Sprintf("%d", *v)
}
func redirectUserEdit(ctx *gin.Context, userID string, errMsg string, successMsg string) {
base := "/account/users/" + userID
if errMsg != "" {
ctx.Redirect(http.StatusSeeOther, base+"?error="+errMsg)
} else if successMsg != "" {
ctx.Redirect(http.StatusSeeOther, base+"?success="+successMsg)
} else {
ctx.Redirect(http.StatusSeeOther, base)
}
}
func containsString(slice []string, s string) bool {
for _, v := range slice {
if v == s {
return true
}
}
return false
}
func removeString(slice []string, s string) []string {
out := make([]string, 0, len(slice))
for _, v := range slice {
if v != s {
out = append(out, v)
}
}
return out
}
func boolPtr(b bool) *bool {
return &b
}