camper/pkg/app/user.go

271 lines
7.3 KiB
Go
Raw Normal View History

/*
* 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
}
Add the logout button Conceptually, to logout we have to “delete the session”, thus the best HTTP verb would be `DELETE`. However, there is no way to send a `DELETE` request with a regular HTML form, and it seems that never will be[0]. I could use a POST, optionally with a “method override” technique, but i was planing to use HTMx anyway, so this was as good an opportunity to include it as any. In this application i am not concerned with people not having JavaScript enabled, because it is for a customer that has a known environment, and we do not have much time anyway. Therefore, i opted to forgo progressive enhancement in cases like this: if `DELETE` is needed, use `hx-delete`. Unfortunately, i can not use a <form> with a hidden <input> for the CSRF token, because `DELETE` requests do not have body and the value should be added as query parameters, like a form with GET method, but HTMx does the incorrect thing here: sends the values in the request’s body. That’s why i have to use a custom header and the `hx-header` directive to include the CSRF token. Then, by default HTMx targets the triggered element for swap with the response from the server, but after a logout i want to redirect the user to the login form again. I could set the hx-target to button to replace the whole body, or tell the client to redirect to the new location. I actually do not know which one is “better”. Maybe the hx-target is best because then everything is handled by the client, but in the case of logout, since it is possible that i might want to load scripts only for logged-in users in the future, i opted for the full page reload. However, HTMx does not want to reload a page that return HTTP 401, hence i had to include the GET method to /login in order to return the login form with a response of HTTP 200, which also helps when reloading in the browser after a failed login attempt. I am not worried with the HTTP 401 when attempting to load a page as guest, because this request most probably comes from the browser, not HTMx, and it will show the login form as intended—even though it is not compliant, since it does not return the WWW-Authenticate header, but this is the best i can do given that no cookie-based authentication method has been accepted[1]. [0]: https://www.w3.org/Bugs/Public/show_bug.cgi?id=10671#c16 [1]: https://datatracker.ietf.org/doc/id/draft-broyer-http-cookie-auth-00.html
2023-07-26 11:49:47 +00:00
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, conn *database.Conn) http.HandlerFunc {
Add the logout button Conceptually, to logout we have to “delete the session”, thus the best HTTP verb would be `DELETE`. However, there is no way to send a `DELETE` request with a regular HTML form, and it seems that never will be[0]. I could use a POST, optionally with a “method override” technique, but i was planing to use HTMx anyway, so this was as good an opportunity to include it as any. In this application i am not concerned with people not having JavaScript enabled, because it is for a customer that has a known environment, and we do not have much time anyway. Therefore, i opted to forgo progressive enhancement in cases like this: if `DELETE` is needed, use `hx-delete`. Unfortunately, i can not use a <form> with a hidden <input> for the CSRF token, because `DELETE` requests do not have body and the value should be added as query parameters, like a form with GET method, but HTMx does the incorrect thing here: sends the values in the request’s body. That’s why i have to use a custom header and the `hx-header` directive to include the CSRF token. Then, by default HTMx targets the triggered element for swap with the response from the server, but after a logout i want to redirect the user to the login form again. I could set the hx-target to button to replace the whole body, or tell the client to redirect to the new location. I actually do not know which one is “better”. Maybe the hx-target is best because then everything is handled by the client, but in the case of logout, since it is possible that i might want to load scripts only for logged-in users in the future, i opted for the full page reload. However, HTMx does not want to reload a page that return HTTP 401, hence i had to include the GET method to /login in order to return the login form with a response of HTTP 200, which also helps when reloading in the browser after a failed login attempt. I am not worried with the HTTP 401 when attempting to load a page as guest, because this request most probably comes from the browser, not HTMx, and it will show the login form as intended—even though it is not compliant, since it does not return the WWW-Authenticate header, but this is the best i can do given that no cookie-based authentication method has been accepted[1]. [0]: https://www.w3.org/Bugs/Public/show_bug.cgi?id=10671#c16 [1]: https://datatracker.ietf.org/doc/id/draft-broyer-http-cookie-auth-00.html
2023-07-26 11:49:47 +00:00
return func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = shiftPath(r.URL.Path)
switch head {
case "avatar":
switch r.Method {
case http.MethodGet:
h.serveAvatar(w, r, user)
default:
methodNotAllowed(w, r, http.MethodGet)
}
Add the logout button Conceptually, to logout we have to “delete the session”, thus the best HTTP verb would be `DELETE`. However, there is no way to send a `DELETE` request with a regular HTML form, and it seems that never will be[0]. I could use a POST, optionally with a “method override” technique, but i was planing to use HTMx anyway, so this was as good an opportunity to include it as any. In this application i am not concerned with people not having JavaScript enabled, because it is for a customer that has a known environment, and we do not have much time anyway. Therefore, i opted to forgo progressive enhancement in cases like this: if `DELETE` is needed, use `hx-delete`. Unfortunately, i can not use a <form> with a hidden <input> for the CSRF token, because `DELETE` requests do not have body and the value should be added as query parameters, like a form with GET method, but HTMx does the incorrect thing here: sends the values in the request’s body. That’s why i have to use a custom header and the `hx-header` directive to include the CSRF token. Then, by default HTMx targets the triggered element for swap with the response from the server, but after a logout i want to redirect the user to the login form again. I could set the hx-target to button to replace the whole body, or tell the client to redirect to the new location. I actually do not know which one is “better”. Maybe the hx-target is best because then everything is handled by the client, but in the case of logout, since it is possible that i might want to load scripts only for logged-in users in the future, i opted for the full page reload. However, HTMx does not want to reload a page that return HTTP 401, hence i had to include the GET method to /login in order to return the login form with a response of HTTP 200, which also helps when reloading in the browser after a failed login attempt. I am not worried with the HTTP 401 when attempting to load a page as guest, because this request most probably comes from the browser, not HTMx, and it will show the login form as intended—even though it is not compliant, since it does not return the WWW-Authenticate header, but this is the best i can do given that no cookie-based authentication method has been accepted[1]. [0]: https://www.w3.org/Bugs/Public/show_bug.cgi?id=10671#c16 [1]: https://datatracker.ietf.org/doc/id/draft-broyer-http-cookie-auth-00.html
2023-07-26 11:49:47 +00:00
case "session":
switch r.Method {
case http.MethodDelete:
handleLogout(w, r, user, conn)
default:
methodNotAllowed(w, r, http.MethodDelete)
}
case "":
switch r.Method {
case http.MethodGet:
serveProfileForm(w, r, user, conn)
case http.MethodPut:
h.updateProfile(w, r, user, conn)
default:
methodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
Add the logout button Conceptually, to logout we have to “delete the session”, thus the best HTTP verb would be `DELETE`. However, there is no way to send a `DELETE` request with a regular HTML form, and it seems that never will be[0]. I could use a POST, optionally with a “method override” technique, but i was planing to use HTMx anyway, so this was as good an opportunity to include it as any. In this application i am not concerned with people not having JavaScript enabled, because it is for a customer that has a known environment, and we do not have much time anyway. Therefore, i opted to forgo progressive enhancement in cases like this: if `DELETE` is needed, use `hx-delete`. Unfortunately, i can not use a <form> with a hidden <input> for the CSRF token, because `DELETE` requests do not have body and the value should be added as query parameters, like a form with GET method, but HTMx does the incorrect thing here: sends the values in the request’s body. That’s why i have to use a custom header and the `hx-header` directive to include the CSRF token. Then, by default HTMx targets the triggered element for swap with the response from the server, but after a logout i want to redirect the user to the login form again. I could set the hx-target to button to replace the whole body, or tell the client to redirect to the new location. I actually do not know which one is “better”. Maybe the hx-target is best because then everything is handled by the client, but in the case of logout, since it is possible that i might want to load scripts only for logged-in users in the future, i opted for the full page reload. However, HTMx does not want to reload a page that return HTTP 401, hence i had to include the GET method to /login in order to return the login form with a response of HTTP 200, which also helps when reloading in the browser after a failed login attempt. I am not worried with the HTTP 401 when attempting to load a page as guest, because this request most probably comes from the browser, not HTMx, and it will show the login form as intended—even though it is not compliant, since it does not return the WWW-Authenticate header, but this is the best i can do given that no cookie-based authentication method has been accepted[1]. [0]: https://www.w3.org/Bugs/Public/show_bug.cgi?id=10671#c16 [1]: https://datatracker.ietf.org/doc/id/draft-broyer-http-cookie-auth-00.html
2023-07-26 11:49:47 +00:00
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, 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)
}
func (h *profileHandler) updateProfile(w http.ResponseWriter, r *http.Request, user *auth.User, 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)
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) {
template.MustRender(w, r, user, "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
}