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

View File

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

View File

@ -7,7 +7,12 @@ package app
import ( import (
"context" "context"
"io"
"net/http" "net/http"
"os"
"path"
"path/filepath"
"strconv"
"golang.org/x/text/language" "golang.org/x/text/language"
@ -30,9 +35,9 @@ func (h *App) getUser(r *http.Request, conn *database.Conn) (*auth.User, error)
LoggedIn: false, LoggedIn: false,
Role: "guest", 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 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 return nil, err
} }
if lang, err := language.Parse(langTag); err == nil { if lang, err := language.Parse(langTag); err == nil {
@ -57,12 +62,37 @@ func (h *App) matchLocale(r *http.Request) *locale.Locale {
return l 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) { return func(w http.ResponseWriter, r *http.Request) {
var head string var head string
head, r.URL.Path = shiftPath(r.URL.Path) head, r.URL.Path = shiftPath(r.URL.Path)
switch head { switch head {
case "avatar":
switch r.Method {
case http.MethodGet:
h.serveAvatar(w, r, user)
default:
methodNotAllowed(w, r, http.MethodGet)
}
case "session": case "session":
switch r.Method { switch r.Method {
case http.MethodDelete: case http.MethodDelete:
@ -75,7 +105,7 @@ func profileHandler(user *auth.User, conn *database.Conn) http.HandlerFunc {
case http.MethodGet: case http.MethodGet:
serveProfileForm(w, r, user, conn) serveProfileForm(w, r, user, conn)
case http.MethodPut: case http.MethodPut:
updateProfile(w, r, user, conn) h.updateProfile(w, r, user, conn)
default: default:
methodNotAllowed(w, r, http.MethodGet, http.MethodPut) 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) { func serveProfileForm(w http.ResponseWriter, r *http.Request, user *auth.User, conn *database.Conn) {
profile := newProfileForm(r.Context(), user.Locale, conn) profile := newProfileForm(r.Context(), user.Locale, conn)
profile.Name.Val = conn.MustGetText(r.Context(), "select name from user_profile") 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) 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) 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) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
defer profile.Close()
if err := user.VerifyCSRFToken(r); err != nil { if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden) http.Error(w, err.Error(), http.StatusForbidden)
return return
@ -116,10 +162,18 @@ func updateProfile(w http.ResponseWriter, r *http.Request, user *auth.User, conn
if profile.Password.Val != "" { if profile.Password.Val != "" {
conn.MustExec(r.Context(), "select change_password($1)", profile.Password) conn.MustExec(r.Context(), "select change_password($1)", profile.Password)
} }
if user.Language.String() == profile.Language.String() { redirect := user.Language.String() != profile.Language.String()
httplib.Relocate(w, r, "/me", http.StatusSeeOther) if profile.HasAvatarFile() {
} else { 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) 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 Password *form.Input
PasswordConfirm *form.Input PasswordConfirm *form.Input
Language *form.Select Language *form.Select
Avatar *form.File
} }
func newProfileForm(ctx context.Context, l *locale.Locale, conn *database.Conn) *profileForm { 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", Name: "language",
Options: languages, Options: languages,
}, },
Avatar: &form.File{
Name: "avatar",
MaxSize: 1 << 20,
},
} }
} }
func (f *profileForm) Parse(r *http.Request) error { func (f *profileForm) Parse(w http.ResponseWriter, r *http.Request) error {
if err := r.ParseForm(); err != nil { maxSize := f.Avatar.MaxSize + 1024
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
if err := r.ParseMultipartForm(maxSize); err != nil {
return err return err
} }
f.Email.FillValue(r) f.Email.FillValue(r)
@ -163,9 +224,16 @@ func (f *profileForm) Parse(r *http.Request) error {
f.Password.FillValue(r) f.Password.FillValue(r)
f.PasswordConfirm.FillValue(r) f.PasswordConfirm.FillValue(r)
f.Language.FillValue(r) f.Language.FillValue(r)
if err := f.Avatar.FillValue(r); err != nil {
return err
}
return nil return nil
} }
func (f *profileForm) Close() error {
return f.Avatar.Close()
}
func (f *profileForm) Valid(l *locale.Locale) bool { func (f *profileForm) Valid(l *locale.Locale) bool {
v := form.NewValidator(l) v := form.NewValidator(l)
if v.CheckRequired(f.Email, l.GettextNoop("Email can not be empty.")) { 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.CheckRequired(f.Name, l.GettextNoop("Name can not be empty."))
v.CheckPasswordConfirmation(f.Password, f.PasswordConfirm, l.GettextNoop("Confirmation does not match password.")) v.CheckPasswordConfirmation(f.Password, f.PasswordConfirm, l.GettextNoop("Confirmation does not match password."))
v.CheckSelectedOptions(f.Language, l.GettextNoop("Selected language is not valid.")) 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 return v.AllOK
} }
func (f *profileForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User) { func (f *profileForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User) {
template.MustRender(w, r, user, "profile.gohtml", f) 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 { type User struct {
ID int
Email string Email string
LoggedIn bool LoggedIn bool
Role string 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) 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 { type field interface {
setError(error) setError(error)
} }

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: camper\n" "Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\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" "PO-Revision-Date: 2023-07-22 23:45+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Catalan <ca@dodds.net>\n" "Language-Team: Catalan <ca@dodds.net>\n"
@ -28,12 +28,12 @@ msgctxt "title"
msgid "Login" msgid "Login"
msgstr "Entrada" msgstr "Entrada"
#: web/templates/login.gohtml:22 web/templates/profile.gohtml:26 #: web/templates/login.gohtml:22 web/templates/profile.gohtml:35
msgctxt "input" msgctxt "input"
msgid "Email" msgid "Email"
msgstr "Correu-e" 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" msgctxt "input"
msgid "Password" msgid "Password"
msgstr "Contrasenya" msgstr "Contrasenya"
@ -50,21 +50,31 @@ msgid "Profile"
msgstr "Perfil" msgstr "Perfil"
#: web/templates/profile.gohtml:17 #: web/templates/profile.gohtml:17
msgctxt "inut"
msgid "Profile Image"
msgstr "Imatge del perfil"
#: web/templates/profile.gohtml:26
msgctxt "input" msgctxt "input"
msgid "Name" msgid "Name"
msgstr "Nom" 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" msgctxt "input"
msgid "Password Confirmation" msgid "Password Confirmation"
msgstr "Confirmació de la contrasenya" msgstr "Confirmació de la contrasenya"
#: web/templates/profile.gohtml:55 #: web/templates/profile.gohtml:65
msgctxt "input" msgctxt "input"
msgid "Language" msgid "Language"
msgstr "Idioma" msgstr "Idioma"
#: web/templates/profile.gohtml:65 #: web/templates/profile.gohtml:75
msgctxt "action" msgctxt "action"
msgid "Save changes" msgid "Save changes"
msgstr "Desa els canvis" msgstr "Desa els canvis"
@ -83,11 +93,11 @@ msgctxt "action"
msgid "Logout" msgid "Logout"
msgstr "Surt" 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." msgid "Email can not be empty."
msgstr "No podeu deixar el correu en blanc." 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." 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." 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." msgid "Invalid user or password."
msgstr "Nom dusuari o contrasenya incorrectes." msgstr "Nom dusuari o contrasenya incorrectes."
#: pkg/app/user.go:135 #: pkg/app/user.go:190
msgctxt "language option" msgctxt "language option"
msgid "Automatic" msgid "Automatic"
msgstr "Automàtic" msgstr "Automàtic"
#: pkg/app/user.go:173 #: pkg/app/user.go:242
msgid "Name can not be empty." msgid "Name can not be empty."
msgstr "No podeu deixar el nom en blanc." msgstr "No podeu deixar el nom en blanc."
#: pkg/app/user.go:174 #: pkg/app/user.go:243
msgid "Confirmation does not match password." msgid "Confirmation does not match password."
msgstr "La confirmació no es correspon amb la contrasenya." 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." msgid "Selected language is not valid."
msgstr "Lidioma escollit no és vàlid." 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." msgid "Cross-site request forgery detected."
msgstr "Sha detectat un intent de falsificació de petició a llocs creuats." msgstr "Sha detectat un intent de falsificació de petició a llocs creuats."

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: camper\n" "Project-Id-Version: camper\n"
"Report-Msgid-Bugs-To: jordi@tandem.blog\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" "PO-Revision-Date: 2023-07-22 23:46+0200\n"
"Last-Translator: jordi fita mas <jordi@tandem.blog>\n" "Last-Translator: jordi fita mas <jordi@tandem.blog>\n"
"Language-Team: Spanish <es@tp.org.es>\n" "Language-Team: Spanish <es@tp.org.es>\n"
@ -28,12 +28,12 @@ msgctxt "title"
msgid "Login" msgid "Login"
msgstr "Entrada" msgstr "Entrada"
#: web/templates/login.gohtml:22 web/templates/profile.gohtml:26 #: web/templates/login.gohtml:22 web/templates/profile.gohtml:35
msgctxt "input" msgctxt "input"
msgid "Email" msgid "Email"
msgstr "Correo-e" 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" msgctxt "input"
msgid "Password" msgid "Password"
msgstr "Contraseña" msgstr "Contraseña"
@ -50,21 +50,31 @@ msgid "Profile"
msgstr "Perfil" msgstr "Perfil"
#: web/templates/profile.gohtml:17 #: web/templates/profile.gohtml:17
msgctxt "inut"
msgid "Profile Image"
msgstr "Imagen del perfil"
#: web/templates/profile.gohtml:26
msgctxt "input" msgctxt "input"
msgid "Name" msgid "Name"
msgstr "Nombre" 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" msgctxt "input"
msgid "Password Confirmation" msgid "Password Confirmation"
msgstr "Confirmación de la contraseña" msgstr "Confirmación de la contraseña"
#: web/templates/profile.gohtml:55 #: web/templates/profile.gohtml:65
msgctxt "input" msgctxt "input"
msgid "Language" msgid "Language"
msgstr "Idioma" msgstr "Idioma"
#: web/templates/profile.gohtml:65 #: web/templates/profile.gohtml:75
msgctxt "action" msgctxt "action"
msgid "Save changes" msgid "Save changes"
msgstr "Guardar los cambios" msgstr "Guardar los cambios"
@ -83,11 +93,11 @@ msgctxt "action"
msgid "Logout" msgid "Logout"
msgstr "Salir" 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." msgid "Email can not be empty."
msgstr "No podéis dejar el correo-e en blanco." 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." 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." 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." msgid "Invalid user or password."
msgstr "Usuario o contraseña incorrectos." msgstr "Usuario o contraseña incorrectos."
#: pkg/app/user.go:135 #: pkg/app/user.go:190
msgctxt "language option" msgctxt "language option"
msgid "Automatic" msgid "Automatic"
msgstr "Automático" msgstr "Automático"
#: pkg/app/user.go:173 #: pkg/app/user.go:242
msgid "Name can not be empty." msgid "Name can not be empty."
msgstr "No podéis dejar el nombre en blanco." 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." msgid "Confirmation does not match password."
msgstr "La confirmación no se corresponde con la contraseña." 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." msgid "Selected language is not valid."
msgstr "El idioma escogido no es válido." 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." msgid "Cross-site request forgery detected."
msgstr "Se ha detectado un intento de falsificación de petición en sitios cruzados." msgstr "Se ha detectado un intento de falsificación de petición en sitios cruzados."

View File

@ -20,7 +20,7 @@
<nav> <nav>
<details> <details>
<summary> <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> <span>{{( pgettext "User Menu" "title" )}}</span>
</summary> </summary>
<ul role="menu"> <ul role="menu">

View File

@ -8,10 +8,19 @@
{{ define "content" -}} {{ define "content" -}}
{{- /*gotype: dev.tandem.ws/tandem/camper/pkg/app.profileForm*/ -}} {{- /*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> <h2>{{( pgettext "Profile" "title" )}}</h2>
{{ CSRFInput }} {{ CSRFInput }}
<fieldset> <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 -}} {{ with .Name -}}
<label> <label>
{{( pgettext "Name" "input" )}}<br> {{( pgettext "Name" "input" )}}<br>
@ -31,6 +40,7 @@
{{ template "error-message" . }} {{ template "error-message" . }}
{{- end }} {{- end }}
<fieldset> <fieldset>
<legend>{{( pgettext "Change password" "legend" )}}</legend>
{{ with .Password -}} {{ with .Password -}}
<label> <label>
{{( pgettext "Password" "input" )}}<br> {{( pgettext "Password" "input" )}}<br>