feat(subsonic): add avatar support

closes: #228
This commit is contained in:
Brian Doherty
2022-07-20 23:16:13 +01:00
committed by sentriz
parent 7ab378accb
commit 5e66261f0c
15 changed files with 288 additions and 28 deletions

View File

@@ -0,0 +1,26 @@
{{ define "user" }}
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-account-key"></i> changing {{ .SelectedUser.Name }}'s avatar
</div>
{{ if ne (len .SelectedUser.Avatar) 0 }}
<form class="block" action="{{ printf "/admin/delete_avatar_do?user=%s" .SelectedUser.Name | path }}" method="post">
<input type="submit" value="delete avatar">
</form>
{{ end }}
<form
class="block file-upload"
enctype="multipart/form-data"
action="{{ printf "/admin/change_avatar_do?user=%s" .SelectedUser.Name | path }}"
method="post"
>
<div style="position: relative;">
<input style="position: absolute; opacity: 0;" name="avatar" type="file" accept="image/jpeg image/png image/gif" />
<input type="button" value="upload avatar">
</div>
{{ if ne (len .SelectedUser.Avatar) 0 }}
<p><img class="avatar-preview" src="data:image/jpg;base64,{{ .SelectedUser.Avatar | base64 }}"/></p>
{{ end }}
</form>
</div>
{{ end }}

View File

@@ -0,0 +1,26 @@
{{ define "user" }}
<div class="padded box">
<div class="box-title">
<i class="mdi mdi-account-key"></i> changing account avatar
</div>
{{ if ne (len .SelectedUser.Avatar) 0 }}
<form class="block" action="{{ path "/admin/delete_own_avatar_do" }}" method="post">
<input type="submit" value="delete avatar">
</form>
{{ end }}
<form
class="block file-upload"
enctype="multipart/form-data"
action="{{ path "/admin/change_own_avatar_do" }}"
method="post"
>
<div style="position: relative;">
<input style="position: absolute; opacity: 0;" name="avatar" type="file" accept="image/jpeg image/png image/gif" />
<input type="button" value="upload avatar">
</div>
{{ if ne (len .SelectedUser.Avatar) 0 }}
<p><img class="avatar-preview" src="data:image/jpg;base64,{{ .SelectedUser.Avatar | base64 }}"/></p>
{{ end }}
</form>
</div>
{{ end }}

View File

@@ -82,6 +82,8 @@
<span class="text-light">&#124;</span>
<a href="{{ printf "/admin/change_password?user=%s" $user.Name | path }}">password&#8230;</a>
<span class="text-light">&#124;</span>
<a href="{{ printf "/admin/change_avatar?user=%s" $user.Name | path }}">change avatar&#8230;</a>
<span class="text-light">&#124;</span>
{{ if $user.IsAdmin }}
<span class="text-light">delete&#8230;</span>
{{ else }}
@@ -100,6 +102,8 @@
<a href="{{ path "/admin/change_own_username" }}" class="button">change username&#8230;</a>
<span class="text-light">&#124;</span>
<a href="{{ path "/admin/change_own_password" }}" class="button">change password&#8230;</a>
<span class="text-light">&#124;</span>
<a href="{{ path "/admin/change_own_avatar" }}" class="button">change avatar&#8230;</a>
</div>
{{ end }}
</div>
@@ -260,17 +264,16 @@
{{ end }}
</table>
<form
id="playlist-upload-form"
class="file-upload"
enctype="multipart/form-data"
action="{{ path "/admin/upload_playlist_do" }}"
method="post"
>
<div style="position: relative;">
<input id="playlist-upload-input" style="position: absolute; opacity: 0;" name="playlist-files" type="file" multiple />
<input style="position: absolute; opacity: 0;" name="playlist-files" type="file" multiple />
<input type="button" value="upload m3u8">
</div>
</form>
<script src="{{ path "/admin/static/playlist-upload.js" }}"></script>
</div>
</div>
{{ end }}

View File

@@ -5,4 +5,5 @@
<link rel="shortcut icon" href="{{ path "/admin/static/favicon.ico" }}" type="image/x-icon">
<link rel="icon" href="{{ path "/admin/static/favicon.ico" }}" type="image/x-icon">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<script src="{{ path "/admin/static/main.js" }}" defer></script>
{{ end }}

View File

@@ -178,3 +178,9 @@ a:hover {
.angry {
background-color: #f4433669;
}
.avatar-preview {
width: 64px;
height: 64px;
object-fit: cover;
}

View File

@@ -0,0 +1,5 @@
for (const form of document.querySelectorAll("form.file-upload") || []) {
const input = form.querySelector("input[type=file]");
if (!input) continue;
input.onchange = (e) => form.submit();
}

View File

@@ -1,3 +0,0 @@
document.getElementById("playlist-upload-input").onchange = e => {
document.getElementById("playlist-upload-form").submit();
};

View File

@@ -2,6 +2,7 @@
package ctrladmin
import (
"encoding/base64"
"encoding/gob"
"errors"
"fmt"
@@ -21,10 +22,10 @@ import (
"github.com/sentriz/gormstore"
"go.senan.xyz/gonic"
"go.senan.xyz/gonic/server/assets"
"go.senan.xyz/gonic/server/ctrlbase"
"go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/podcasts"
"go.senan.xyz/gonic/server/assets"
"go.senan.xyz/gonic/server/ctrlbase"
)
type CtxKey int
@@ -47,6 +48,7 @@ func funcMap() template.FuncMap {
return strings.ToLower(in.Format("Jan 02, 2006"))
},
"dateHuman": humanize.Time,
"base64": base64.StdEncoding.EncodeToString,
}
}
@@ -122,8 +124,11 @@ type templateData struct {
DefaultListenBrainzURL string
SelectedUser *db.User
Podcasts []*db.Podcast
Podcasts []*db.Podcast
InternetRadioStations []*db.InternetRadioStation
// avatar
Avatar []byte
}
type Response struct {

View File

@@ -1,7 +1,12 @@
package ctrladmin
import (
"bytes"
"fmt"
"image"
_ "image/gif" // to decode uploaded GIF avatars
"image/jpeg"
_ "image/png" // to decode uploaded PNG avatars
"log"
"net/http"
"net/url"
@@ -9,6 +14,7 @@ import (
"time"
"github.com/mmcdole/gofeed"
"github.com/nfnt/resize"
"go.senan.xyz/gonic/db"
"go.senan.xyz/gonic/scanner"
@@ -120,6 +126,37 @@ func (c *Controller) ServeChangeOwnPasswordDo(r *http.Request) *Response {
return &Response{redirect: "/admin/home"}
}
func (c *Controller) ServeChangeOwnAvatar(r *http.Request) *Response {
data := &templateData{}
user := r.Context().Value(CtxUser).(*db.User)
data.SelectedUser = user
return &Response{
template: "change_own_avatar.tmpl",
data: data,
}
}
func (c *Controller) ServeChangeOwnAvatarDo(r *http.Request) *Response {
user := r.Context().Value(CtxUser).(*db.User)
avatar, err := getAvatarFile(r)
if err != nil {
return &Response{
redirect: r.Referer(),
flashW: []string{err.Error()},
}
}
user.Avatar = avatar
c.DB.Save(user)
return &Response{redirect: "/admin/home"}
}
func (c *Controller) ServeDeleteOwnAvatarDo(r *http.Request) *Response {
user := r.Context().Value(CtxUser).(*db.User)
user.Avatar = nil
c.DB.Save(user)
return &Response{redirect: "/admin/home"}
}
func (c *Controller) ServeLinkLastFMDo(r *http.Request) *Response {
token := r.URL.Query().Get("token")
if token == "" {
@@ -242,6 +279,46 @@ func (c *Controller) ServeChangePasswordDo(r *http.Request) *Response {
return &Response{redirect: "/admin/home"}
}
func (c *Controller) ServeChangeAvatar(r *http.Request) *Response {
username := r.URL.Query().Get("user")
if username == "" {
return &Response{code: 400, err: "please provide a username"}
}
user := c.DB.GetUserByName(username)
if user == nil {
return &Response{code: 400, err: "couldn't find a user with that name"}
}
data := &templateData{}
data.SelectedUser = user
return &Response{
template: "change_avatar.tmpl",
data: data,
}
}
func (c *Controller) ServeChangeAvatarDo(r *http.Request) *Response {
username := r.URL.Query().Get("user")
user := c.DB.GetUserByName(username)
avatar, err := getAvatarFile(r)
if err != nil {
return &Response{
redirect: r.Referer(),
flashW: []string{err.Error()},
}
}
user.Avatar = avatar
c.DB.Save(user)
return &Response{redirect: "/admin/home"}
}
func (c *Controller) ServeDeleteAvatarDo(r *http.Request) *Response {
username := r.URL.Query().Get("user")
user := c.DB.GetUserByName(username)
user.Avatar = nil
c.DB.Save(user)
return &Response{redirect: "/admin/home"}
}
func (c *Controller) ServeDeleteUser(r *http.Request) *Response {
username := r.URL.Query().Get("user")
if username == "" {
@@ -278,8 +355,7 @@ func (c *Controller) ServeCreateUser(r *http.Request) *Response {
func (c *Controller) ServeCreateUserDo(r *http.Request) *Response {
username := r.FormValue("username")
err := validateUsername(username)
if err != nil {
if err := validateUsername(username); err != nil {
return &Response{
redirect: r.Referer(),
flashW: []string{err.Error()},
@@ -287,8 +363,7 @@ func (c *Controller) ServeCreateUserDo(r *http.Request) *Response {
}
passwordOne := r.FormValue("password_one")
passwordTwo := r.FormValue("password_two")
err = validatePasswords(passwordOne, passwordTwo)
if err != nil {
if err := validatePasswords(passwordOne, passwordTwo); err != nil {
return &Response{
redirect: r.Referer(),
flashW: []string{err.Error()},
@@ -570,3 +645,24 @@ func (c *Controller) ServeInternetRadioStationDeleteDo(r *http.Request) *Respons
redirect: "/admin/home",
}
}
func getAvatarFile(r *http.Request) ([]byte, error) {
err := r.ParseMultipartForm(10 << 20) // keep up to 10MB in memory
if err != nil {
return nil, err
}
file, _, err := r.FormFile("avatar")
if err != nil {
return nil, fmt.Errorf("read form file: %w", err)
}
i, _, err := image.Decode(file)
if err != nil {
return nil, fmt.Errorf("decode image: %w", err)
}
resized := resize.Resize(64, 64, i, resize.Lanczos3)
var buff bytes.Buffer
if err := jpeg.Encode(&buff, resized, nil); err != nil {
return nil, err
}
return buff.Bytes(), nil
}

View File

@@ -1,6 +1,7 @@
package ctrlsubsonic
import (
"bytes"
"errors"
"fmt"
"log"
@@ -297,3 +298,18 @@ func (c *Controller) ServeStream(w http.ResponseWriter, r *http.Request) *spec.R
}
return nil
}
func (c *Controller) ServeGetAvatar(w http.ResponseWriter, r *http.Request) *spec.Response {
params := r.Context().Value(CtxParams).(params.Params)
user := r.Context().Value(CtxUser).(*db.User)
username, err := params.Get("username")
if err != nil {
return spec.NewError(10, "please provide an `username` parameter")
}
reqUser := c.DB.GetUserByName(username)
if (user != reqUser) && !user.IsAdmin {
return spec.NewError(50, "user not admin")
}
http.ServeContent(w, r, "", time.Now(), bytes.NewReader(reqUser.Avatar))
return nil
}

View File

@@ -161,6 +161,9 @@ func setupAdmin(r *mux.Router, ctrl *ctrladmin.Controller) {
routUser.Handle("/change_own_username_do", ctrl.H(ctrl.ServeChangeOwnUsernameDo))
routUser.Handle("/change_own_password", ctrl.H(ctrl.ServeChangeOwnPassword))
routUser.Handle("/change_own_password_do", ctrl.H(ctrl.ServeChangeOwnPasswordDo))
routUser.Handle("/change_own_avatar", ctrl.H(ctrl.ServeChangeOwnAvatar))
routUser.Handle("/change_own_avatar_do", ctrl.H(ctrl.ServeChangeOwnAvatarDo))
routUser.Handle("/delete_own_avatar_do", ctrl.H(ctrl.ServeDeleteOwnAvatarDo))
routUser.Handle("/link_lastfm_do", ctrl.H(ctrl.ServeLinkLastFMDo))
routUser.Handle("/unlink_lastfm_do", ctrl.H(ctrl.ServeUnlinkLastFMDo))
routUser.Handle("/link_listenbrainz_do", ctrl.H(ctrl.ServeLinkListenBrainzDo))
@@ -177,6 +180,9 @@ func setupAdmin(r *mux.Router, ctrl *ctrladmin.Controller) {
routAdmin.Handle("/change_username_do", ctrl.H(ctrl.ServeChangeUsernameDo))
routAdmin.Handle("/change_password", ctrl.H(ctrl.ServeChangePassword))
routAdmin.Handle("/change_password_do", ctrl.H(ctrl.ServeChangePasswordDo))
routAdmin.Handle("/change_avatar", ctrl.H(ctrl.ServeChangeAvatar))
routAdmin.Handle("/change_avatar_do", ctrl.H(ctrl.ServeChangeAvatarDo))
routAdmin.Handle("/delete_avatar_do", ctrl.H(ctrl.ServeDeleteAvatarDo))
routAdmin.Handle("/delete_user", ctrl.H(ctrl.ServeDeleteUser))
routAdmin.Handle("/delete_user_do", ctrl.H(ctrl.ServeDeleteUserDo))
routAdmin.Handle("/create_user", ctrl.H(ctrl.ServeCreateUser))
@@ -235,6 +241,7 @@ func setupSubsonic(r *mux.Router, ctrl *ctrlsubsonic.Controller) {
r.Handle("/getCoverArt{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeGetCoverArt))
r.Handle("/stream{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeStream))
r.Handle("/download{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeStream))
r.Handle("/getAvatar{_:(?:\\.view)?}", ctrl.HR(ctrl.ServeGetAvatar))
// browse by tag
r.Handle("/getAlbum{_:(?:\\.view)?}", ctrl.H(ctrl.ServeGetAlbum))