271 lines
7.5 KiB
Go
271 lines
7.5 KiB
Go
/*
|
|
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strconv"
|
|
|
|
"golang.org/x/text/language"
|
|
|
|
"dev.tandem.ws/tandem/camper/pkg/auth"
|
|
"dev.tandem.ws/tandem/camper/pkg/database"
|
|
"dev.tandem.ws/tandem/camper/pkg/form"
|
|
httplib "dev.tandem.ws/tandem/camper/pkg/http"
|
|
"dev.tandem.ws/tandem/camper/pkg/locale"
|
|
"dev.tandem.ws/tandem/camper/pkg/template"
|
|
)
|
|
|
|
func (h *App) getUser(r *http.Request, conn *database.Conn) (*auth.User, error) {
|
|
cookie := auth.GetSessionCookie(r)
|
|
if _, err := conn.Exec(r.Context(), "select set_cookie($1)", cookie); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
user := &auth.User{
|
|
Email: "",
|
|
LoggedIn: false,
|
|
Role: "guest",
|
|
}
|
|
row := conn.QueryRow(r.Context(), "select user_id, coalesce(email, ''), email is not null, role, lang_tag, csrf_token from user_profile")
|
|
var langTag string
|
|
if err := row.Scan(&user.ID, &user.Email, &user.LoggedIn, &user.Role, &langTag, &user.CSRFToken); err != nil {
|
|
return nil, err
|
|
}
|
|
if lang, err := language.Parse(langTag); err == nil {
|
|
user.Language = lang
|
|
} else {
|
|
return nil, err
|
|
}
|
|
|
|
user.Locale = h.locales[user.Language]
|
|
if user.Locale == nil {
|
|
user.Locale = h.matchLocale(r)
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func (h *App) matchLocale(r *http.Request) *locale.Locale {
|
|
l := locale.Match(r.Header.Get("Accept-Language"), h.locales, h.languageMatcher)
|
|
if l == nil {
|
|
l = h.defaultLocale
|
|
}
|
|
return l
|
|
}
|
|
|
|
type profileHandler struct {
|
|
fileHandle http.Handler
|
|
avatarHandle http.Handler
|
|
avatarsDir string
|
|
}
|
|
|
|
func newProfileHandler(static http.Handler, avatarsDir string) (*profileHandler, error) {
|
|
if err := os.MkdirAll(avatarsDir, 0755); err != nil {
|
|
return nil, err
|
|
}
|
|
handler := &profileHandler{
|
|
fileHandle: static,
|
|
avatarsDir: avatarsDir,
|
|
avatarHandle: http.FileServer(http.Dir(avatarsDir)),
|
|
}
|
|
return handler, nil
|
|
}
|
|
|
|
func (h *profileHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var head string
|
|
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
|
|
|
|
switch head {
|
|
case "avatar":
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
h.serveAvatar(w, r, user)
|
|
default:
|
|
httplib.MethodNotAllowed(w, r, http.MethodGet)
|
|
}
|
|
case "session":
|
|
switch r.Method {
|
|
case http.MethodDelete:
|
|
handleLogout(w, r, user, conn)
|
|
default:
|
|
httplib.MethodNotAllowed(w, r, http.MethodDelete)
|
|
}
|
|
case "":
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
serveProfileForm(w, r, user, company, conn)
|
|
case http.MethodPut:
|
|
h.updateProfile(w, r, user, company, conn)
|
|
default:
|
|
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
|
|
}
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *profileHandler) serveAvatar(w http.ResponseWriter, r *http.Request, user *auth.User) {
|
|
avatarPath := h.avatarPath(user)
|
|
if _, err := os.Stat(avatarPath); err == nil {
|
|
r.URL.Path = path.Base(avatarPath)
|
|
h.avatarHandle.ServeHTTP(w, r)
|
|
} else {
|
|
r.URL.Path = "default_avatar.svg"
|
|
h.fileHandle.ServeHTTP(w, r)
|
|
}
|
|
}
|
|
|
|
func (h *profileHandler) avatarPath(user *auth.User) string {
|
|
return filepath.Join(h.avatarsDir, strconv.Itoa(user.ID))
|
|
}
|
|
|
|
func serveProfileForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
|
|
profile := newProfileForm(r.Context(), user.Locale, conn)
|
|
profile.Name.Val = conn.MustGetText(r.Context(), "select name from user_profile")
|
|
profile.Email.Val = user.Email
|
|
profile.Language.Selected = []string{user.Language.String()}
|
|
profile.MustRender(w, r, user, company)
|
|
}
|
|
|
|
func (h *profileHandler) updateProfile(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
|
|
profile := newProfileForm(r.Context(), user.Locale, conn)
|
|
if err := profile.Parse(w, r); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer profile.Close()
|
|
if err := user.VerifyCSRFToken(r); err != nil {
|
|
http.Error(w, err.Error(), http.StatusForbidden)
|
|
return
|
|
}
|
|
if !profile.Valid(user.Locale) {
|
|
if !httplib.IsHTMxRequest(r) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
}
|
|
profile.MustRender(w, r, user, company)
|
|
return
|
|
}
|
|
//goland:noinspection SqlWithoutWhere
|
|
cookie := conn.MustGetText(r.Context(), "update user_profile set name = $1, email = $2, lang_tag = $3 returning build_cookie()", profile.Name, profile.Email, profile.Language)
|
|
auth.SetSessionCookie(w, cookie)
|
|
if profile.Password.Val != "" {
|
|
conn.MustExec(r.Context(), "select change_password($1)", profile.Password)
|
|
}
|
|
redirect := user.Language.String() != profile.Language.String()
|
|
if profile.HasAvatarFile() {
|
|
redirect = true
|
|
if err := profile.SaveAvatarTo(h.avatarPath(user)); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
if redirect {
|
|
httplib.Redirect(w, r, "/me", http.StatusSeeOther)
|
|
} else {
|
|
httplib.Relocate(w, r, "/me", http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
type profileForm struct {
|
|
Name *form.Input
|
|
Email *form.Input
|
|
Password *form.Input
|
|
PasswordConfirm *form.Input
|
|
Language *form.Select
|
|
Avatar *form.File
|
|
}
|
|
|
|
func newProfileForm(ctx context.Context, l *locale.Locale, conn *database.Conn) *profileForm {
|
|
automaticOption := l.Pgettext("Automatic", "language option")
|
|
languages := form.MustGetOptions(ctx, conn, "select 'und', $1 union all select lang_tag, endonym from language where selectable", automaticOption)
|
|
return &profileForm{
|
|
Name: &form.Input{
|
|
Name: "name",
|
|
},
|
|
Email: &form.Input{
|
|
Name: "email",
|
|
},
|
|
Password: &form.Input{
|
|
Name: "password",
|
|
},
|
|
PasswordConfirm: &form.Input{
|
|
Name: "password_confirm",
|
|
},
|
|
Language: &form.Select{
|
|
Name: "language",
|
|
Options: languages,
|
|
},
|
|
Avatar: &form.File{
|
|
Name: "avatar",
|
|
MaxSize: 1 << 20,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (f *profileForm) Parse(w http.ResponseWriter, r *http.Request) error {
|
|
maxSize := f.Avatar.MaxSize + 1024
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
|
|
if err := r.ParseMultipartForm(maxSize); err != nil {
|
|
return err
|
|
}
|
|
f.Email.FillValue(r)
|
|
f.Name.FillValue(r)
|
|
f.Password.FillValue(r)
|
|
f.PasswordConfirm.FillValue(r)
|
|
f.Language.FillValue(r)
|
|
if err := f.Avatar.FillValue(r); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *profileForm) Close() error {
|
|
return f.Avatar.Close()
|
|
}
|
|
|
|
func (f *profileForm) Valid(l *locale.Locale) bool {
|
|
v := form.NewValidator(l)
|
|
if v.CheckRequired(f.Email, l.GettextNoop("Email can not be empty.")) {
|
|
v.CheckValidEmail(f.Email, l.GettextNoop("This email is not valid. It should be like name@domain.com."))
|
|
}
|
|
v.CheckRequired(f.Name, l.GettextNoop("Name can not be empty."))
|
|
v.CheckPasswordConfirmation(f.Password, f.PasswordConfirm, l.GettextNoop("Confirmation does not match password."))
|
|
v.CheckSelectedOptions(f.Language, l.GettextNoop("Selected language is not valid."))
|
|
if f.HasAvatarFile() {
|
|
v.CheckImageFile(f.Avatar, l.GettextNoop("File must be a valid PNG or JPEG image."))
|
|
}
|
|
return v.AllOK
|
|
}
|
|
|
|
func (f *profileForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
|
|
template.MustRender(w, r, user, company, "profile.gohtml", f)
|
|
}
|
|
|
|
func (f *profileForm) HasAvatarFile() bool {
|
|
return f.Avatar.HasData()
|
|
}
|
|
|
|
func (f *profileForm) SaveAvatarTo(avatarPath string) error {
|
|
if !f.HasAvatarFile() {
|
|
return nil
|
|
}
|
|
avatar, err := os.OpenFile(avatarPath, os.O_WRONLY|os.O_CREATE, 0644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer avatar.Close()
|
|
_, err = io.Copy(avatar, f.Avatar)
|
|
return err
|
|
}
|