558 lines
15 KiB
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
|
|
}
|