/* * SPDX-FileCopyrightText: 2023 jordi fita mas * 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 } 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 = httplib.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: 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) } 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 }