From b03ef35a95388371f8e35a0d79be3268639e0e32 Mon Sep 17 00:00:00 2001 From: jordi fita mas Date: Fri, 28 Jul 2023 20:15:09 +0200 Subject: [PATCH] Allow users to update their profile images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/camper/main.go | 8 ++- pkg/app/app.go | 15 +++-- pkg/app/user.go | 110 +++++++++++++++++++++++++++++++---- pkg/auth/user.go | 1 + pkg/form/file.go | 63 ++++++++++++++++++++ pkg/form/validator.go | 4 ++ po/ca.po | 40 ++++++++----- po/es.po | 40 ++++++++----- web/templates/layout.gohtml | 2 +- web/templates/profile.gohtml | 12 +++- 10 files changed, 251 insertions(+), 44 deletions(-) create mode 100644 pkg/form/file.go diff --git a/cmd/camper/main.go b/cmd/camper/main.go index ac38f7b..c18cdfa 100644 --- a/cmd/camper/main.go +++ b/cmd/camper/main.go @@ -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, diff --git a/pkg/app/app.go b/pkg/app/app.go index 8150070..4c1009f 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -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: diff --git a/pkg/app/user.go b/pkg/app/user.go index 74f27a6..e4fa953 100644 --- a/pkg/app/user.go +++ b/pkg/app/user.go @@ -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 +} diff --git a/pkg/auth/user.go b/pkg/auth/user.go index 05be619..733ded9 100644 --- a/pkg/auth/user.go +++ b/pkg/auth/user.go @@ -20,6 +20,7 @@ const ( ) type User struct { + ID int Email string LoggedIn bool Role string diff --git a/pkg/form/file.go b/pkg/form/file.go new file mode 100644 index 0000000..4160564 --- /dev/null +++ b/pkg/form/file.go @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2023 jordi fita mas + * 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) +} diff --git a/pkg/form/validator.go b/pkg/form/validator.go index 38a9882..f003c69 100644 --- a/pkg/form/validator.go +++ b/pkg/form/validator.go @@ -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) } diff --git a/po/ca.po b/po/ca.po index 700e7b2..5863268 100644 --- a/po/ca.po +++ b/po/ca.po @@ -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 \n" "Language-Team: Catalan \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 d’usuari 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 "L’idioma 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 "S’ha detectat un intent de falsificació de petició a llocs creuats." diff --git a/po/es.po b/po/es.po index 8faa109..614b44d 100644 --- a/po/es.po +++ b/po/es.po @@ -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 \n" "Language-Team: Spanish \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." diff --git a/web/templates/layout.gohtml b/web/templates/layout.gohtml index f96a338..e31badb 100644 --- a/web/templates/layout.gohtml +++ b/web/templates/layout.gohtml @@ -20,7 +20,7 @@