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 @@