Allow users to update their profile images

I do not see the profile image as an “integral part” of the user (i.e.,
no need for constraints), hence i do not want to store it in the
database, as would do for the identification image during check-in.

By default, i store the avatars in /var/lib/camper/avatars, but it is a
variable to allow packagers change this value using the linker.

This is also served as a test bed for uploading files to the server,
that now has a better interface and uses less resources that what i did
to Numerus.

Now the profile handler needs to keep a variable to know the path to the
avatars’ directory, thus i had to change it to a struct nested in app,
much like the fileHandler does.  It still has to return the HandlerFunc,
however, as this function needs to close over the user and connection
variables.

Part of #7.
This commit is contained in:
jordi fita mas 2023-07-28 20:15:09 +02:00
parent bf0c86a796
commit b03ef35a95
10 changed files with 251 additions and 44 deletions

View File

@ -18,6 +18,8 @@ import (
"dev.tandem.ws/tandem/camper/pkg/database"
)
var avatarsDir = "/var/lib/camper/avatars"
func main() {
db, err := database.New(context.Background(), os.Getenv("CAMPER_DATABASE_URL"))
if err != nil {
@ -25,9 +27,13 @@ func main() {
}
defer db.Close()
handler, err := app.New(db, avatarsDir)
if err != nil {
log.Fatal(err)
}
srv := http.Server{
Addr: ":8080",
Handler: app.New(db),
Handler: handler,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 2 * time.Minute,

View File

@ -36,16 +36,23 @@ func methodNotAllowed(w http.ResponseWriter, _ *http.Request, allowed ...string)
type App struct {
db *database.DB
fileHandler http.Handler
profile *profileHandler
locales locale.Locales
defaultLocale *locale.Locale
languageMatcher language.Matcher
}
func New(db *database.DB) http.Handler {
func New(db *database.DB, avatarsDir string) (http.Handler, error) {
locales := locale.MustGetAll(db)
static := http.FileServer(http.Dir("web/static"))
profile, err := newProfileHandler(static, avatarsDir)
if err != nil {
return nil, err
}
app := &App{
db: db,
fileHandler: http.FileServer(http.Dir("web/static")),
fileHandler: static,
profile: profile,
locales: locales,
defaultLocale: locales[language.Catalan],
languageMatcher: language.NewMatcher(locales.Tags()),
@ -54,7 +61,7 @@ func New(db *database.DB) http.Handler {
var handler http.Handler = app
handler = httplib.RecoverPanic(handler)
handler = httplib.LogRequest(handler)
return handler
return handler, nil
}
func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@ -97,7 +104,7 @@ func (h *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch head {
case "me":
profileHandler(user, conn)(w, r)
h.profile.Handler(user, conn).ServeHTTP(w, r)
case "":
switch r.Method {
case http.MethodGet:

View File

@ -7,7 +7,12 @@ package app
import (
"context"
"io"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"golang.org/x/text/language"
@ -30,9 +35,9 @@ func (h *App) getUser(r *http.Request, conn *database.Conn) (*auth.User, error)
LoggedIn: false,
Role: "guest",
}
row := conn.QueryRow(r.Context(), "select coalesce(email, ''), email is not null, role, lang_tag, csrf_token from user_profile")
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.Email, &user.LoggedIn, &user.Role, &langTag, &user.CSRFToken); err != nil {
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 {
@ -57,12 +62,37 @@ func (h *App) matchLocale(r *http.Request) *locale.Locale {
return l
}
func profileHandler(user *auth.User, conn *database.Conn) http.HandlerFunc {
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 {
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)
}
case "session":
switch r.Method {
case http.MethodDelete:
@ -75,7 +105,7 @@ func profileHandler(user *auth.User, conn *database.Conn) http.HandlerFunc {
case http.MethodGet:
serveProfileForm(w, r, user, conn)
case http.MethodPut:
updateProfile(w, r, user, conn)
h.updateProfile(w, r, user, conn)
default:
methodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
@ -85,6 +115,21 @@ func profileHandler(user *auth.User, conn *database.Conn) http.HandlerFunc {
}
}
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")
@ -93,12 +138,13 @@ func serveProfileForm(w http.ResponseWriter, r *http.Request, user *auth.User, c
profile.MustRender(w, r, user)
}
func updateProfile(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) {
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(r); err != nil {
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
@ -116,10 +162,18 @@ func updateProfile(w http.ResponseWriter, r *http.Request, user *auth.User, conn
if profile.Password.Val != "" {
conn.MustExec(r.Context(), "select change_password($1)", profile.Password)
}
if user.Language.String() == profile.Language.String() {
httplib.Relocate(w, r, "/me", http.StatusSeeOther)
} else {
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)
}
}
@ -129,6 +183,7 @@ type profileForm struct {
Password *form.Input
PasswordConfirm *form.Input
Language *form.Select
Avatar *form.File
}
func newProfileForm(ctx context.Context, l *locale.Locale, conn *database.Conn) *profileForm {
@ -151,11 +206,17 @@ func newProfileForm(ctx context.Context, l *locale.Locale, conn *database.Conn)
Name: "language",
Options: languages,
},
Avatar: &form.File{
Name: "avatar",
MaxSize: 1 << 20,
},
}
}
func (f *profileForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
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)
@ -163,9 +224,16 @@ func (f *profileForm) Parse(r *http.Request) error {
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.")) {
@ -174,9 +242,29 @@ func (f *profileForm) Valid(l *locale.Locale) bool {
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
}

View File

@ -20,6 +20,7 @@ const (
)
type User struct {
ID int
Email string
LoggedIn bool
Role string

63
pkg/form/file.go Normal file
View File

@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2023 jordi fita mas <jfita@peritasoft.com>
* SPDX-License-Identifier: AGPL-3.0-only
*/
package form
import (
"bufio"
"errors"
"io"
"mime/multipart"
"net/http"
)
type File struct {
Name string
MaxSize int64
Val string
Error error
ContentType string
file multipart.File
header *multipart.FileHeader
buffer *bufio.Reader
}
func (f *File) setError(err error) {
f.Error = err
}
func (f *File) FillValue(r *http.Request) error {
var err error
f.file, f.header, err = r.FormFile(f.Name)
if err != nil {
if errors.Is(err, http.ErrMissingFile) {
return nil
}
return err
}
f.buffer = bufio.NewReader(f.file)
sniff, _ := f.buffer.Peek(512)
f.ContentType = http.DetectContentType(sniff)
return nil
}
func (f *File) Close() error {
if !f.HasData() {
return nil
}
return f.file.Close()
}
func (f *File) HasData() bool {
return f.file != nil
}
func (f *File) Read(p []byte) (int, error) {
return f.buffer.Read(p)
}
func (f *File) WriteTo(w io.Writer) (int64, error) {
return f.buffer.WriteTo(w)
}

View File

@ -40,6 +40,10 @@ func (v *Validator) CheckSelectedOptions(field *Select, message string) bool {
return v.check(field, field.validOptionsSelected(), message)
}
func (v *Validator) CheckImageFile(field *File, message string) bool {
return v.check(field, field.ContentType == "image/png" || field.ContentType == "image/jpeg", message)
}
type field interface {
setError(error)
}

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-07-26 20:36+0200\n"
"POT-Creation-Date: 2023-07-28 20:01+0200\n"
"PO-Revision-Date: 2023-07-22 23:45+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n"
@ -28,12 +28,12 @@ msgctxt "title"
msgid "Login"
msgstr "Entrada"
#: web/templates/login.gohtml:22 web/templates/profile.gohtml:26
#: web/templates/login.gohtml:22 web/templates/profile.gohtml:35
msgctxt "input"
msgid "Email"
msgstr "Correu-e"
#: web/templates/login.gohtml:31 web/templates/profile.gohtml:36
#: web/templates/login.gohtml:31 web/templates/profile.gohtml:46
msgctxt "input"
msgid "Password"
msgstr "Contrasenya"
@ -50,21 +50,31 @@ msgid "Profile"
msgstr "Perfil"
#: web/templates/profile.gohtml:17
msgctxt "inut"
msgid "Profile Image"
msgstr "Imatge del perfil"
#: web/templates/profile.gohtml:26
msgctxt "input"
msgid "Name"
msgstr "Nom"
#: web/templates/profile.gohtml:45
#: web/templates/profile.gohtml:43
msgctxt "legend"
msgid "Change password"
msgstr "Canvi de contrasenya"
#: web/templates/profile.gohtml:55
msgctxt "input"
msgid "Password Confirmation"
msgstr "Confirmació de la contrasenya"
#: web/templates/profile.gohtml:55
#: web/templates/profile.gohtml:65
msgctxt "input"
msgid "Language"
msgstr "Idioma"
#: web/templates/profile.gohtml:65
#: web/templates/profile.gohtml:75
msgctxt "action"
msgid "Save changes"
msgstr "Desa els canvis"
@ -83,11 +93,11 @@ msgctxt "action"
msgid "Logout"
msgstr "Surt"
#: pkg/app/login.go:56 pkg/app/user.go:170
#: pkg/app/login.go:56 pkg/app/user.go:239
msgid "Email can not be empty."
msgstr "No podeu deixar el correu en blanc."
#: pkg/app/login.go:57 pkg/app/user.go:171
#: pkg/app/login.go:57 pkg/app/user.go:240
msgid "This email is not valid. It should be like name@domain.com."
msgstr "Aquest correu-e no és vàlid. Hauria de ser similar a nom@domini.com."
@ -99,23 +109,27 @@ msgstr "No podeu deixar la contrasenya en blanc."
msgid "Invalid user or password."
msgstr "Nom dusuari o contrasenya incorrectes."
#: pkg/app/user.go:135
#: pkg/app/user.go:190
msgctxt "language option"
msgid "Automatic"
msgstr "Automàtic"
#: pkg/app/user.go:173
#: pkg/app/user.go:242
msgid "Name can not be empty."
msgstr "No podeu deixar el nom en blanc."
#: pkg/app/user.go:174
#: pkg/app/user.go:243
msgid "Confirmation does not match password."
msgstr "La confirmació no es correspon amb la contrasenya."
#: pkg/app/user.go:175
#: pkg/app/user.go:244
msgid "Selected language is not valid."
msgstr "Lidioma escollit no és vàlid."
#: pkg/auth/user.go:39
#: pkg/app/user.go:246
msgid "File must be a valid PNG or JPEG image."
msgstr "El fitxer has de ser una imatge PNG o JPEG vàlida."
#: pkg/auth/user.go:40
msgid "Cross-site request forgery detected."
msgstr "Sha detectat un intent de falsificació de petició a llocs creuats."

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\n"
"POT-Creation-Date: 2023-07-26 20:36+0200\n"
"POT-Creation-Date: 2023-07-28 20:02+0200\n"
"PO-Revision-Date: 2023-07-22 23:46+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n"
@ -28,12 +28,12 @@ msgctxt "title"
msgid "Login"
msgstr "Entrada"
#: web/templates/login.gohtml:22 web/templates/profile.gohtml:26
#: web/templates/login.gohtml:22 web/templates/profile.gohtml:35
msgctxt "input"
msgid "Email"
msgstr "Correo-e"
#: web/templates/login.gohtml:31 web/templates/profile.gohtml:36
#: web/templates/login.gohtml:31 web/templates/profile.gohtml:46
msgctxt "input"
msgid "Password"
msgstr "Contraseña"
@ -50,21 +50,31 @@ msgid "Profile"
msgstr "Perfil"
#: web/templates/profile.gohtml:17
msgctxt "inut"
msgid "Profile Image"
msgstr "Imagen del perfil"
#: web/templates/profile.gohtml:26
msgctxt "input"
msgid "Name"
msgstr "Nombre"
#: web/templates/profile.gohtml:45
#: web/templates/profile.gohtml:43
msgctxt "legend"
msgid "Change password"
msgstr "Cambio de contraseña"
#: web/templates/profile.gohtml:55
msgctxt "input"
msgid "Password Confirmation"
msgstr "Confirmación de la contraseña"
#: web/templates/profile.gohtml:55
#: web/templates/profile.gohtml:65
msgctxt "input"
msgid "Language"
msgstr "Idioma"
#: web/templates/profile.gohtml:65
#: web/templates/profile.gohtml:75
msgctxt "action"
msgid "Save changes"
msgstr "Guardar los cambios"
@ -83,11 +93,11 @@ msgctxt "action"
msgid "Logout"
msgstr "Salir"
#: pkg/app/login.go:56 pkg/app/user.go:170
#: pkg/app/login.go:56 pkg/app/user.go:239
msgid "Email can not be empty."
msgstr "No podéis dejar el correo-e en blanco."
#: pkg/app/login.go:57 pkg/app/user.go:171
#: pkg/app/login.go:57 pkg/app/user.go:240
msgid "This email is not valid. It should be like name@domain.com."
msgstr "Este correo-e no es válido. Tiene que ser parecido a nombre@dominio.com."
@ -99,23 +109,27 @@ msgstr "No podéis dejar la contraseña en blanco."
msgid "Invalid user or password."
msgstr "Usuario o contraseña incorrectos."
#: pkg/app/user.go:135
#: pkg/app/user.go:190
msgctxt "language option"
msgid "Automatic"
msgstr "Automático"
#: pkg/app/user.go:173
#: pkg/app/user.go:242
msgid "Name can not be empty."
msgstr "No podéis dejar el nombre en blanco."
#: pkg/app/user.go:174
#: pkg/app/user.go:243
msgid "Confirmation does not match password."
msgstr "La confirmación no se corresponde con la contraseña."
#: pkg/app/user.go:175
#: pkg/app/user.go:244
msgid "Selected language is not valid."
msgstr "El idioma escogido no es válido."
#: pkg/auth/user.go:39
#: pkg/app/user.go:246
msgid "File must be a valid PNG or JPEG image."
msgstr "El archivo tiene que ser una imagen PNG o JPEG válida."
#: pkg/auth/user.go:40
msgid "Cross-site request forgery detected."
msgstr "Se ha detectado un intento de falsificación de petición en sitios cruzados."

View File

@ -20,7 +20,7 @@
<nav>
<details>
<summary>
<img src="/static/default_avatar.svg" alt="{{( gettext "Avatar" )}}" width="70" height="70">
<img src="/me/avatar" alt="{{( gettext "Avatar" )}}" width="70" height="70">
<span>{{( pgettext "User Menu" "title" )}}</span>
</summary>
<ul role="menu">

View File

@ -8,10 +8,19 @@
{{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/app.profileForm*/ -}}
<form data-hx-put="/me">
<form data-hx-put="/me" enctype="multipart/form-data">
<h2>{{( pgettext "Profile" "title" )}}</h2>
{{ CSRFInput }}
<fieldset>
{{ with .Avatar -}}
<label>
{{( pgettext "Profile Image" "inut" )}}<br>
<input type="file" name="{{ .Name }}"
accept="image/png image/jpeg"
{{ template "error-attrs" . }}><br>
</label>
{{ template "error-message" . }}
{{- end }}
{{ with .Name -}}
<label>
{{( pgettext "Name" "input" )}}<br>
@ -31,6 +40,7 @@
{{ template "error-message" . }}
{{- end }}
<fieldset>
<legend>{{( pgettext "Change password" "legend" )}}</legend>
{{ with .Password -}}
<label>
{{( pgettext "Password" "input" )}}<br>