Let admins change and revoke user avatars.

Linkify avatars in the user manager too.

Add the panel_user_avatar phrase.
This commit is contained in:
Azareal 2019-06-11 08:00:57 +10:00
parent 199a841bc3
commit ee3c29b136
11 changed files with 556 additions and 442 deletions

View File

@ -7,6 +7,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"os"
"io"
"regexp"
"github.com/Azareal/Gosora/common/phrases" "github.com/Azareal/Gosora/common/phrases"
) )
@ -322,6 +325,97 @@ func preRoute(w http.ResponseWriter, r *http.Request) (User, bool) {
return *usercpy, true return *usercpy, true
} }
func UploadAvatar(w http.ResponseWriter, r *http.Request, user User, tuid int) (ext string, ferr RouteError) {
// We don't want multiple files
// TODO: Are we doing this correctly?
filenameMap := make(map[string]bool)
for _, fheaders := range r.MultipartForm.File {
for _, hdr := range fheaders {
if hdr.Filename == "" {
continue
}
filenameMap[hdr.Filename] = true
}
}
if len(filenameMap) > 1 {
return "", LocalError("You may only upload one avatar", w, r, user)
}
for _, fheaders := range r.MultipartForm.File {
for _, hdr := range fheaders {
if hdr.Filename == "" {
continue
}
infile, err := hdr.Open()
if err != nil {
return "", LocalError("Upload failed", w, r, user)
}
defer infile.Close()
if ext == "" {
extarr := strings.Split(hdr.Filename, ".")
if len(extarr) < 2 {
return "", LocalError("Bad file", w, r, user)
}
ext = extarr[len(extarr)-1]
// TODO: Can we do this without a regex?
reg, err := regexp.Compile("[^A-Za-z0-9]+")
if err != nil {
return "", LocalError("Bad file extension", w, r, user)
}
ext = reg.ReplaceAllString(ext, "")
ext = strings.ToLower(ext)
if !ImageFileExts.Contains(ext) {
return "", LocalError("You can only use an image for your avatar", w, r, user)
}
}
// TODO: Centralise this string, so we don't have to change it in two different places when it changes
outfile, err := os.Create("./uploads/avatar_" + strconv.Itoa(tuid) + "." + ext)
if err != nil {
return "", LocalError("Upload failed [File Creation Failed]", w, r, user)
}
defer outfile.Close()
_, err = io.Copy(outfile, infile)
if err != nil {
return "", LocalError("Upload failed [Copy Failed]", w, r, user)
}
}
}
if ext == "" {
return "", LocalError("No file", w, r, user)
}
return ext, nil
}
func ChangeAvatar(path string, w http.ResponseWriter, r *http.Request, user User) RouteError {
err := user.ChangeAvatar(path)
if err != nil {
return InternalError(err, w, r)
}
// Clean up the old avatar data, so we don't end up with too many dead files in /uploads/
if len(user.RawAvatar) > 2 {
if user.RawAvatar[0] == '.' && user.RawAvatar[1] == '.' {
err := os.Remove("./uploads/avatar_" + strconv.Itoa(user.ID) + "_tmp" + user.RawAvatar[1:])
if err != nil && !os.IsNotExist(err) {
LogWarning(err)
return LocalError("Something went wrong", w, r, user)
}
err = os.Remove("./uploads/avatar_" + strconv.Itoa(user.ID) + "_w48" + user.RawAvatar[1:])
if err != nil && !os.IsNotExist(err) {
LogWarning(err)
return LocalError("Something went wrong", w, r, user)
}
}
}
return nil
}
// SuperAdminOnly makes sure that only super admin can access certain critical panel routes // SuperAdminOnly makes sure that only super admin can access certain critical panel routes
func SuperAdminOnly(w http.ResponseWriter, r *http.Request, user User) RouteError { func SuperAdminOnly(w http.ResponseWriter, r *http.Request, user User) RouteError {
if !user.IsSuperAdmin { if !user.IsSuperAdmin {

File diff suppressed because it is too large Load Diff

View File

@ -796,6 +796,7 @@
"panel_users_activate":"Activate", "panel_users_activate":"Activate",
"panel_user_head":"User Editor", "panel_user_head":"User Editor",
"panel_user_avatar":"Avatar",
"panel_user_name":"Name", "panel_user_name":"Name",
"panel_user_name_placeholder":"Jane Doe", "panel_user_name_placeholder":"Jane Doe",
"panel_user_password":"Password", "panel_user_password":"Password",

View File

@ -199,6 +199,8 @@ func panelRoutes() *RouteGroup {
View("panel.Users", "/panel/users/"), View("panel.Users", "/panel/users/"),
View("panel.UsersEdit", "/panel/users/edit/", "extraData"), View("panel.UsersEdit", "/panel/users/edit/", "extraData"),
Action("panel.UsersEditSubmit", "/panel/users/edit/submit/", "extraData"), Action("panel.UsersEditSubmit", "/panel/users/edit/submit/", "extraData"),
UploadAction("panel.UsersAvatarSubmit", "/panel/users/avatar/submit/", "extraData").MaxSizeVar("int(c.Config.MaxRequestSize)"),
Action("panel.UsersAvatarRemoveSubmit", "/panel/users/avatar/remove/submit/", "extraData"),
View("panel.AnalyticsViews", "/panel/analytics/views/").Before("ParseForm"), View("panel.AnalyticsViews", "/panel/analytics/views/").Before("ParseForm"),
View("panel.AnalyticsRoutes", "/panel/analytics/routes/").Before("ParseForm"), View("panel.AnalyticsRoutes", "/panel/analytics/routes/").Before("ParseForm"),

View File

@ -6,12 +6,9 @@ import (
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"html" "html"
"io"
"log" "log"
"math" "math"
"net/http" "net/http"
"os"
"regexp"
"strconv" "strconv"
"strings" "strings"
@ -431,93 +428,18 @@ func AccountEditAvatarSubmit(w http.ResponseWriter, r *http.Request, user c.User
return c.NoPermissions(w, r, user) return c.NoPermissions(w, r, user)
} }
// We don't want multiple files ext, ferr := c.UploadAvatar(w,r,user,user.ID)
// TODO: Are we doing this correctly? if ferr != nil {
filenameMap := make(map[string]bool) return ferr
for _, fheaders := range r.MultipartForm.File {
for _, hdr := range fheaders {
if hdr.Filename == "" {
continue
}
filenameMap[hdr.Filename] = true
}
}
if len(filenameMap) > 1 {
return c.LocalError("You may only upload one avatar", w, r, user)
} }
var ext string ferr = c.ChangeAvatar("." + ext, w, r, user)
for _, fheaders := range r.MultipartForm.File { if ferr != nil {
for _, hdr := range fheaders { return ferr
if hdr.Filename == "" {
continue
}
infile, err := hdr.Open()
if err != nil {
return c.LocalError("Upload failed", w, r, user)
}
defer infile.Close()
if ext == "" {
extarr := strings.Split(hdr.Filename, ".")
if len(extarr) < 2 {
return c.LocalError("Bad file", w, r, user)
}
ext = extarr[len(extarr)-1]
// TODO: Can we do this without a regex?
reg, err := regexp.Compile("[^A-Za-z0-9]+")
if err != nil {
return c.LocalError("Bad file extension", w, r, user)
}
ext = reg.ReplaceAllString(ext, "")
ext = strings.ToLower(ext)
if !c.ImageFileExts.Contains(ext) {
return c.LocalError("You can only use an image for your avatar", w, r, user)
}
}
// TODO: Centralise this string, so we don't have to change it in two different places when it changes
outfile, err := os.Create("./uploads/avatar_" + strconv.Itoa(user.ID) + "." + ext)
if err != nil {
return c.LocalError("Upload failed [File Creation Failed]", w, r, user)
}
defer outfile.Close()
_, err = io.Copy(outfile, infile)
if err != nil {
return c.LocalError("Upload failed [Copy Failed]", w, r, user)
}
}
}
if ext == "" {
return c.LocalError("No file", w, r, user)
}
err := user.ChangeAvatar("." + ext)
if err != nil {
return c.InternalError(err, w, r)
}
// Clean up the old avatar data, so we don't end up with too many dead files in /uploads/
if len(user.RawAvatar) > 2 {
if user.RawAvatar[0] == '.' && user.RawAvatar[1] == '.' {
err := os.Remove("./uploads/avatar_" + strconv.Itoa(user.ID) + "_tmp" + user.RawAvatar[1:])
if err != nil && !os.IsNotExist(err) {
c.LogWarning(err)
return c.LocalError("Something went wrong", w, r, user)
}
err = os.Remove("./uploads/avatar_" + strconv.Itoa(user.ID) + "_w48" + user.RawAvatar[1:])
if err != nil && !os.IsNotExist(err) {
c.LogWarning(err)
return c.LocalError("Something went wrong", w, r, user)
}
}
} }
// TODO: Only schedule a resize if the avatar isn't tiny // TODO: Only schedule a resize if the avatar isn't tiny
err = user.ScheduleAvatarResize() err := user.ScheduleAvatarResize()
if err != nil { if err != nil {
return c.InternalError(err, w, r) return c.InternalError(err, w, r)
} }
@ -531,25 +453,9 @@ func AccountEditRevokeAvatarSubmit(w http.ResponseWriter, r *http.Request, user
return ferr return ferr
} }
err := user.ChangeAvatar("") ferr = c.ChangeAvatar("", w, r, user)
if err != nil { if ferr != nil {
return c.InternalError(err, w, r) return ferr
}
// Clean up the old avatar data, so we don't end up with too many dead files in /uploads/
if len(user.RawAvatar) > 2 {
if user.RawAvatar[0] == '.' && user.RawAvatar[1] == '.' {
err := os.Remove("./uploads/avatar_" + strconv.Itoa(user.ID) + "_tmp" + user.RawAvatar[1:])
if err != nil && !os.IsNotExist(err) {
c.LogWarning(err)
return c.LocalError("Something went wrong", w, r, user)
}
err = os.Remove("./uploads/avatar_" + strconv.Itoa(user.ID) + "_w48" + user.RawAvatar[1:])
if err != nil && !os.IsNotExist(err) {
c.LogWarning(err)
return c.LocalError("Something went wrong", w, r, user)
}
}
} }
http.Redirect(w, r, "/user/edit/?avatar_updated=1", http.StatusSeeOther) http.Redirect(w, r, "/user/edit/?avatar_updated=1", http.StatusSeeOther)

View File

@ -98,7 +98,6 @@ func UsersEditSubmit(w http.ResponseWriter, r *http.Request, user c.User, suid s
} else if err != nil { } else if err != nil {
return c.InternalError(err, w, r) return c.InternalError(err, w, r)
} }
if targetUser.IsAdmin && !user.IsAdmin { if targetUser.IsAdmin && !user.IsAdmin {
return c.LocalError("Only administrators can edit the account of other administrators.", w, r, user) return c.LocalError("Only administrators can edit the account of other administrators.", w, r, user)
} }
@ -162,3 +161,80 @@ func UsersEditSubmit(w http.ResponseWriter, r *http.Request, user c.User, suid s
} }
return nil return nil
} }
func UsersAvatarSubmit(w http.ResponseWriter, r *http.Request, user c.User, suid string) c.RouteError {
_, ferr := c.SimplePanelUserCheck(w, r, &user)
if ferr != nil {
return ferr
}
if !user.Perms.EditUser {
return c.NoPermissions(w, r, user)
}
uid, err := strconv.Atoi(suid)
if err != nil {
return c.LocalError("The provided UserID is not a valid number.", w, r, user)
}
targetUser, err := c.Users.Get(uid)
if err == sql.ErrNoRows {
return c.LocalError("The user you're trying to edit doesn't exist.", w, r, user)
} else if err != nil {
return c.InternalError(err, w, r)
}
if targetUser.IsAdmin && !user.IsAdmin {
return c.LocalError("Only administrators can edit the account of other administrators.", w, r, user)
}
ext, ferr := c.UploadAvatar(w,r,user,targetUser.ID)
if ferr != nil {
return ferr
}
ferr = c.ChangeAvatar("." + ext, w, r, *targetUser)
if ferr != nil {
return ferr
}
// TODO: Only schedule a resize if the avatar isn't tiny
err = targetUser.ScheduleAvatarResize()
if err != nil {
return c.InternalError(err, w, r)
}
http.Redirect(w, r, "/panel/users/edit/"+strconv.Itoa(targetUser.ID)+"?updated=1", http.StatusSeeOther)
return nil
}
func UsersAvatarRemoveSubmit(w http.ResponseWriter, r *http.Request, user c.User, suid string) c.RouteError {
_, ferr := c.SimplePanelUserCheck(w, r, &user)
if ferr != nil {
return ferr
}
if !user.Perms.EditUser {
return c.NoPermissions(w, r, user)
}
uid, err := strconv.Atoi(suid)
if err != nil {
return c.LocalError("The provided UserID is not a valid number.", w, r, user)
}
targetUser, err := c.Users.Get(uid)
if err == sql.ErrNoRows {
return c.LocalError("The user you're trying to edit doesn't exist.", w, r, user)
} else if err != nil {
return c.InternalError(err, w, r)
}
if targetUser.IsAdmin && !user.IsAdmin {
return c.LocalError("Only administrators can edit the account of other administrators.", w, r, user)
}
ferr = c.ChangeAvatar("", w, r, *targetUser)
if ferr != nil {
return ferr
}
http.Redirect(w, r, "/panel/users/edit/"+strconv.Itoa(targetUser.ID)+"?updated=1", http.StatusSeeOther)
return nil
}

View File

@ -1,31 +1,46 @@
<div class="colstack_item colstack_head"> <div class="colstack_item colstack_head">
<div class="rowitem"><h1>{{lang "panel_user_head"}}</h1></div> <div class="rowitem"><h1>{{lang "panel_user_head"}}</h1></div>
</div> </div>
<div id="panel_user" class="colstack_item the_form"> <div id="panel_user" class="colstack_item the_form">
<form action="/panel/users/edit/submit/{{.Something.ID}}?session={{.CurrentUser.Session}}" method="post"> <form id="user_form" action="/panel/users/edit/submit/{{.Something.ID}}?session={{.CurrentUser.Session}}" method="post"></form>
<div class="formrow"> <form id="avatar_form" enctype="multipart/form-data" action="/panel/users/avatar/submit/{{.Something.ID}}?session={{.CurrentUser.Session}}" method="post"></form>
<div class="formitem formlabel"><a>{{lang "panel_user_name"}}</a></div> <form id="remove_avatar_form" action="/panel/users/avatar/remove/submit/{{.Something.ID}}?session={{.CurrentUser.Session}}" method="post"></form>
<div class="formitem"><input name="user-name" type="text" value="{{.Something.Name}}" placeholder="{{lang "panel_user_name_placeholder"}}" /></div> <div class="formrow">
</div> <div class="formitem formlabel"><a>{{lang "panel_user_avatar"}}</a></div>
{{if .CurrentUser.Perms.EditUserPassword}}<div class="formrow"> <div class="formitem avataritem">
<div class="formitem formlabel"><a>{{lang "panel_user_password"}}</a></div> {{if .Something.RawAvatar}}<img src="{{.Something.Avatar}}" height=56 width=56 />{{end}}
<div class="formitem"><input name="user-password" type="password" placeholder="*****" autocomplete="off" /></div> <div class="avatarbuttons">
</div>{{end}} <input form="avatar_form" id="select_avatar" name="avatar_file" type="file" required class="auto_hide" />
{{if .CurrentUser.Perms.EditUserEmail}}<div class="formrow"> <label for="select_avatar" class="formbutton">Select</label>
<div class="formitem formlabel"><a>{{lang "panel_user_email"}}</a></div> <button form="avatar_form" name="avatar_action" value=0>Upload</button>
<div class="formitem"><input name="user-email" type="email" value="{{.Something.Email}}" placeholder="example@localhost" /></div> {{if .Something.RawAvatar}}<button form="remove_avatar_form" name="avatar_action" value=1>Remove</button>{{end}}
</div>{{end}}
{{if .CurrentUser.Perms.EditUserGroup}}
<div class="formrow">
<div class="formitem formlabel"><a>{{lang "panel_user_group"}}</a></div>
<div class="formitem">
<select name="user-group">
{{range .ItemList}}<option {{if eq .ID $.Something.Group}}selected {{end}}value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
</div> </div>
</div>{{end}}
<div class="formrow">
<div class="formitem"><button name="panel-button" class="formbutton">{{lang "panel_user_update_button"}}</button></div>
</div> </div>
</form>
</div> </div>
<div class="formrow">
<div class="formitem formlabel"><a>{{lang "panel_user_name"}}</a></div>
<div class="formitem"><input form="user_form" name="user-name" type="text" value="{{.Something.Name}}" placeholder="{{lang "panel_user_name_placeholder"}}" /></div>
</div>
{{if .CurrentUser.Perms.EditUserPassword}}<div class="formrow">
<div class="formitem formlabel"><a>{{lang "panel_user_password"}}</a></div>
<div class="formitem"><input form="user_form" name="user-password" type="password" placeholder="*****" autocomplete="off" /></div>
</div>{{end}}
{{if .CurrentUser.Perms.EditUserEmail}}<div class="formrow">
<div class="formitem formlabel"><a>{{lang "panel_user_email"}}</a></div>
<div class="formitem"><input form="user_form" name="user-email" type="email" value="{{.Something.Email}}" placeholder="example@localhost" /></div>
</div>{{end}}
{{if .CurrentUser.Perms.EditUserGroup}}
<div class="formrow">
<div class="formitem formlabel"><a>{{lang "panel_user_group"}}</a></div>
<div class="formitem">
<select form="user_form" name="user-group">
{{range .ItemList}}<option {{if eq .ID $.Something.Group}}selected {{end}}value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
</div>
</div>{{end}}
<div class="formrow">
<div class="formitem">
<button form="user_form" name="panel-button" class="formbutton">{{lang "panel_user_update_button"}}</button>
</div>
</div>
</div>

View File

@ -1,18 +1,20 @@
<div class="colstack_item colstack_head"> <div class="colstack_item colstack_head">
<div class="rowitem"><h1>{{lang "panel_users_head"}}</h1></div> <div class="rowitem"><h1>{{lang "panel_users_head"}}</h1></div>
</div> </div>
<div id="panel_users" class="colstack_item rowlist bgavatars"> <div id="panel_users" class="colstack_item rowlist bgavatars">
{{range .ItemList}} {{range .ItemList}}
<div class="rowitem editable_parent" style="background-image: url('{{.Avatar}}');"> <div class="rowitem" style="background-image: url('{{.Avatar}}');">
<a class="rowAvatar"{{if $.CurrentUser.Perms.EditUser}} href="/panel/users/edit/{{.ID}}?session={{$.CurrentUser.Session}}"{{end}}>
<img class="bgsub" src="{{.Avatar}}" alt="Avatar" aria-hidden="true" /> <img class="bgsub" src="{{.Avatar}}" alt="Avatar" aria-hidden="true" />
<a class="rowTitle editable_block"{{if $.CurrentUser.Perms.EditUser}} href="/panel/users/edit/{{.ID}}?session={{$.CurrentUser.Session}}"{{end}}>{{.Name}}</a> </a>
<span class="panel_floater"> <a class="rowTitle"{{if $.CurrentUser.Perms.EditUser}} href="/panel/users/edit/{{.ID}}?session={{$.CurrentUser.Session}}"{{end}}>{{.Name}}</a>
<a href="{{.Link}}" class="tag-mini profile_url">{{lang "panel_users_profile"}}</a> <span class="panel_floater">
{{if (.Tag) and (.IsSuperMod)}}<span class="panel_tag">{{.Tag}}</span></span>{{end}} <a href="{{.Link}}" class="tag-mini profile_url">{{lang "panel_users_profile"}}</a>
{{if .IsBanned}}<a href="/users/unban/{{.ID}}?session={{$.CurrentUser.Session}}" class="panel_tag panel_right_button ban_button">{{lang "panel_users_unban"}}</a>{{else if not .IsSuperMod}}<a href="/user/{{.ID}}#ban_user" class="panel_tag panel_right_button ban_button">{{lang "panel_users_ban"}}</a>{{end}} {{if (.Tag) and (.IsSuperMod)}}<span class="panel_tag">{{.Tag}}</span></span>{{end}}
{{if not .Active}}<a href="/users/activate/{{.ID}}?session={{$.CurrentUser.Session}}" class="panel_tag panel_right_button">{{lang "panel_users_activate"}}</a>{{end}} {{if .IsBanned}}<a href="/users/unban/{{.ID}}?session={{$.CurrentUser.Session}}" class="panel_tag panel_right_button ban_button">{{lang "panel_users_unban"}}</a>{{else if not .IsSuperMod}}<a href="/user/{{.ID}}#ban_user" class="panel_tag panel_right_button ban_button">{{lang "panel_users_ban"}}</a>{{end}}
</span> {{if not .Active}}<a href="/users/activate/{{.ID}}?session={{$.CurrentUser.Session}}" class="panel_tag panel_right_button">{{lang "panel_users_activate"}}</a>{{end}}
</div> </span>
{{end}}
</div> </div>
{{template "paginator.html" . }} {{end}}
</div>
{{template "paginator.html" . }}

View File

@ -754,8 +754,6 @@ textarea {
} }
.rowlist.bgavatars .rowitem { .rowlist.bgavatars .rowitem {
flex-direction: column; flex-direction: column;
}
.rowlist.bgavatars .rowitem {
padding-top: 16px; padding-top: 16px;
padding-bottom: 10px; padding-bottom: 10px;
} }
@ -781,6 +779,9 @@ textarea {
font-size: 20px; font-size: 20px;
margin-bottom: 3px; margin-bottom: 3px;
} }
.rowlist.bgavatars .rowAvatar {
margin-bottom: -4px;
}
.rowlist .panel_compactrow { .rowlist .panel_compactrow {
padding: 16px; padding: 16px;
} }

View File

@ -1074,7 +1074,7 @@ input[type=checkbox]:checked + label .sel {
.rowlist.not_grid .rowitem { .rowlist.not_grid .rowitem {
flex-direction: row; flex-direction: row;
} }
.rowlist.bgavatars .bgsub, .rowlist.bgavatars .rowTitle { .rowlist.bgavatars .bgsub, .rowlist.bgavatars .rowTitle, .rowlist.bgavatars .rowAvatar {
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
@ -1087,6 +1087,9 @@ input[type=checkbox]:checked + label .sel {
font-size: 18px; font-size: 18px;
margin-top: 4px; margin-top: 4px;
} }
.rowlist.bgavatars .rowAvatar {
margin-bottom: -4px;
}
.rowlist.bgavatars.not_grid .bgsub { .rowlist.bgavatars.not_grid .bgsub {
height: 28px; height: 28px;
width: 28px; width: 28px;

View File

@ -95,9 +95,16 @@
.alert { .alert {
margin-top: 18px; margin-top: 18px;
} }
.rowitem { .rowitem, .formitem.avataritem {
display: flex; display: flex;
} }
.formitem.avataritem {
flex-direction: column;
}
.avataritem .avatarbuttons {
margin-top: 7px;
margin-bottom: 3px;
}
.colstack_grid { .colstack_grid {
display: grid; display: grid;
@ -125,31 +132,12 @@
.grid2 { .grid2 {
margin-top: 12px; margin-top: 12px;
} }
/*.panel_dashboard .grid_item {
text-align: center;
}
#dash-cpu, #dash-disk, #dash-ram, #dash-memused {
background-repeat: no-repeat;
padding-left: 61px;
background-position: left 12px top 50%;
background-size: 40px;
}
#dash-cpu {
background-image: url(./fa-svg/server-bg.svg);
background-size: 30px;
}
#dash-disk {
background-image: url(./fa-svg/hdd-bg.svg);
}
#dash-ram, #dash-memused {
background-image: url(./fa-svg/memory.svg);
}*/
.panel_buttons, .panel_floater { .panel_buttons, .panel_floater {
margin-left: auto; margin-left: auto;
} }
.colstack_right input, .colstack_right select, .colstack_right textarea { .colstack_right input, .colstack_right select, .colstack_right textarea, .formitem img {
padding: 4px; padding: 4px;
padding-bottom: 3px; padding-bottom: 3px;
padding-left: 6px; padding-left: 6px;