diff --git a/pkg/config/enum.go b/pkg/config/enum.go index b22c173..116b976 100644 --- a/pkg/config/enum.go +++ b/pkg/config/enum.go @@ -64,4 +64,37 @@ var ( "interests", "music_movies", } + + // Choices for the Contact Us subject + ContactUsChoices = []ContactUs{ + { + Header: "Website Feedback", + Options: []Option{ + {"feedback", "Website feedback"}, + {"feature", "Make a feature request"}, + {"bug", "Report a bug or broken feature"}, + {"other", "General/miscellaneous/other"}, + }, + }, + { + Header: "Report a Problem", + Options: []Option{ + {"report.user", "Report a problematic user"}, + {"report.photo", "Report a problematic photo"}, + {"report.message", "Report a direct message conversation"}, + }, + }, + } ) + +// ContactUs choices for the subject drop-down. +type ContactUs struct { + Header string + Options []Option +} + +// Option for select boxes. +type Option struct { + Value string + Label string +} diff --git a/pkg/config/page_sizes.go b/pkg/config/page_sizes.go index bd70937..df1c39d 100644 --- a/pkg/config/page_sizes.go +++ b/pkg/config/page_sizes.go @@ -6,6 +6,7 @@ var ( PageSizeFriends = 12 PageSizeBlockList = 12 PageSizeAdminCertification = 20 + PageSizeAdminFeedback = 20 PageSizeSiteGallery = 18 PageSizeUserGallery = 18 PageSizeInboxList = 20 // sidebar list diff --git a/pkg/controller/admin/feedback.go b/pkg/controller/admin/feedback.go new file mode 100644 index 0000000..f0fe354 --- /dev/null +++ b/pkg/controller/admin/feedback.go @@ -0,0 +1,162 @@ +package admin + +import ( + "fmt" + "net/http" + "strconv" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/photo" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// Feedback controller (/admin/feedback) +func Feedback() http.HandlerFunc { + tmpl := templates.Must("admin/feedback.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Query params. + var ( + acknowledged = r.FormValue("acknowledged") == "true" + intent = r.FormValue("intent") + visit = r.FormValue("visit") == "true" // visit the linked table ID + profile = r.FormValue("profile") == "true" // visit associated user profile + verdict = r.FormValue("verdict") + fb *models.Feedback + ) + + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get your current user: %s", err) + } + + // Working on a target message? + if idStr := r.FormValue("id"); idStr != "" { + if idInt, err := strconv.Atoi(idStr); err != nil { + session.FlashError(w, r, "Couldn't parse id param: %s", err) + } else { + fb, err = models.GetFeedback(uint64(idInt)) + if err != nil { + session.FlashError(w, r, "Couldn't load feedback message %d: %s", idInt, err) + } + } + } + + // Are we visiting a linked resource (via TableID)? + if fb != nil && fb.TableID > 0 && visit { + switch fb.TableName { + case "users": + user, err := models.GetUser(fb.TableID) + if err != nil { + session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err) + } else { + templates.Redirect(w, "/u/"+user.Username) + return + } + case "photos": + pic, err := models.GetPhoto(fb.TableID) + if err != nil { + session.FlashError(w, r, "Couldn't get photo %d: %s", fb.TableID, err) + } else { + // Going to the user's profile page? + if profile { + user, err := models.GetUser(pic.UserID) + if err != nil { + session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err) + } else { + templates.Redirect(w, "/u/"+user.Username) + return + } + } + + // Direct link to the photo. + templates.Redirect(w, photo.URLPath(pic.Filename)) + return + } + case "messages": + // To read this message we will need to impersonate the reporter. + user, err := models.GetUser(fb.UserID) + if err != nil { + session.FlashError(w, r, "Couldn't get reporting user ID %d: %s", fb.UserID, err) + } else { + if err := session.ImpersonateUser(w, r, user, currentUser); err != nil { + session.FlashError(w, r, "Couldn't impersonate user: %s", err) + } else { + // Redirect to the thread. + session.Flash(w, r, "NOTICE: You are now impersonating %s to view their inbox.", user.Username) + templates.Redirect(w, fmt.Sprintf("/messages/read/%d", fb.TableID)) + return + } + } + default: + session.FlashError(w, r, "Couldn't visit TableID %s/%d: not a supported TableName", fb.TableName, fb.TableID) + } + } + + // Are we (un)acknowledging a message? + if r.Method == http.MethodPost { + if fb == nil { + session.FlashError(w, r, "Missing feedback ID for this POST!") + } else { + switch verdict { + case "acknowledge": + fb.Acknowledged = true + if err := fb.Save(); err != nil { + session.FlashError(w, r, "Couldn't save message: %s", err) + } else { + session.Flash(w, r, "Message acknowledged!") + } + case "unacknowledge": + fb.Acknowledged = false + if err := fb.Save(); err != nil { + session.FlashError(w, r, "Couldn't save message: %s", err) + } else { + session.Flash(w, r, "Message acknowledged!") + } + default: + session.FlashError(w, r, "Unsupported verdict: %s", verdict) + } + } + + templates.Redirect(w, r.URL.Path) + return + } + + // Get the feedback. + pager := &models.Pagination{ + Page: 1, + PerPage: config.PageSizeAdminFeedback, + Sort: "updated_at desc", + } + pager.ParsePage(r) + page, err := models.PaginateFeedback(acknowledged, intent, pager) + if err != nil { + session.FlashError(w, r, "Couldn't load feedback from DB: %s", err) + } + + // Map user IDs. + var userIDs = []uint64{} + for _, p := range page { + if p.UserID > 0 { + userIDs = append(userIDs, p.UserID) + } + } + userMap, err := models.MapUsers(userIDs) + if err != nil { + session.FlashError(w, r, "Couldn't map user IDs: %s", err) + } + + var vars = map[string]interface{}{ + "Intent": intent, + "Acknowledged": acknowledged, + "Feedback": page, + "UserMap": userMap, + "Pager": pager, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/inbox/inbox.go b/pkg/controller/inbox/inbox.go index 82191b0..af3168d 100644 --- a/pkg/controller/inbox/inbox.go +++ b/pkg/controller/inbox/inbox.go @@ -28,11 +28,14 @@ func Inbox() http.HandlerFunc { var showSent = r.FormValue("box") == "sent" // Are we reading a specific message? - var viewThread []*models.Message - var threadPager *models.Pagination - var composeToUsername string + var ( + viewThread []*models.Message + threadPager *models.Pagination + composeToUsername string + msgId int + ) if uri := ReadURLRegexp.FindStringSubmatch(r.URL.Path); uri != nil { - msgId, _ := strconv.Atoi(uri[1]) + msgId, _ = strconv.Atoi(uri[1]) if msg, err := models.GetMessage(uint64(msgId)); err != nil { session.FlashError(w, r, "Message not found.") templates.Redirect(w, "/messages") @@ -127,6 +130,7 @@ func Inbox() http.HandlerFunc { "ViewThread": viewThread, // nil on inbox page "ThreadPager": threadPager, "ReplyTo": composeToUsername, + "MessageID": msgId, } if err := tmpl.Execute(w, r, vars); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/pkg/controller/index/contact.go b/pkg/controller/index/contact.go new file mode 100644 index 0000000..e13ca24 --- /dev/null +++ b/pkg/controller/index/contact.go @@ -0,0 +1,165 @@ +package index + +import ( + "fmt" + "html/template" + "net/http" + "strconv" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/log" + "git.kirsle.net/apps/gosocial/pkg/mail" + "git.kirsle.net/apps/gosocial/pkg/markdown" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// Contact or report a problem. +func Contact() http.HandlerFunc { + tmpl := templates.Must("contact.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Query and form POST parameters. + var ( + intent = r.FormValue("intent") + subject = r.FormValue("subject") + title = "Contact Us" + message = r.FormValue("message") + replyTo = r.FormValue("email") + tableID int + tableName string + tableLabel string // front-end user feedback about selected report item + messageRequired = true // unless we have a table ID to work with + success = "Thank you for your feedback! Your message has been delivered to the website administrators." + ) + + // For report intents: ID of the user, photo, message, etc. + tableID, _ = strconv.Atoi(r.FormValue("id")) + if tableID > 0 { + messageRequired = false + } + + // In what context is the ID given? + if subject != "" && tableID > 0 { + switch subject { + case "report.user": + tableName = "users" + if user, err := models.GetUser(uint64(tableID)); err == nil { + tableLabel = fmt.Sprintf(`User account "%s"`, user.Username) + } else { + log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err) + } + case "report.photo": + tableName = "photos" + + // Find this photo and the user associated. + if pic, err := models.GetPhoto(uint64(tableID)); err == nil { + if user, err := models.GetUser(pic.UserID); err == nil { + tableLabel = fmt.Sprintf(`A profile photo of user account "%s"`, user.Username) + } else { + log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err) + } + } else { + log.Error("/contact: couldn't produce table label for photo %d: %s", tableID, err) + } + case "report.message": + tableName = "messages" + tableLabel = "Direct Message conversation" + } + } + + // On POST: take what we have now and email the admins. + if r.Method == http.MethodPost { + // Look up the current user, in case logged in. + currentUser, err := session.CurrentUser(r) + if err == nil { + replyTo = currentUser.Email + } + + // Store feedback in the database. + fb := &models.Feedback{ + Intent: intent, + Subject: subject, + Message: message, + TableName: tableName, + TableID: uint64(tableID), + } + + if currentUser != nil && currentUser.ID > 0 { + fb.UserID = currentUser.ID + } else if replyTo != "" { + fb.ReplyTo = replyTo + } + + if err := models.CreateFeedback(fb); err != nil { + session.FlashError(w, r, "Couldn't save feedback: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + // Email the admins. + if err := mail.Send(mail.Message{ + To: config.Current.AdminEmail, + Subject: "User Feedback: " + title, + Template: "email/contact_admin.html", + Data: map[string]interface{}{ + "Title": title, + "Intent": intent, + "Subject": subject, + "Message": template.HTML(markdown.Render(message)), + "TableName": tableName, + "TableID": tableID, + "CurrentUser": currentUser, + "ReplyTo": replyTo, + "BaseURL": config.Current.BaseURL, + "AdminURL": config.Current.BaseURL + "/admin/feedback", + }, + }); err != nil { + log.Error("/contact page: couldn't send email: %s", err) + } + + session.Flash(w, r, success) + templates.Redirect(w, r.URL.Path) + return + } + + // Default intent = contact + if intent == "report" { + title = "Report a Problem" + } else { + intent = "contact" + } + + // Validate the subject. + if subject != "" { + var found bool + for _, group := range config.ContactUsChoices { + for _, opt := range group.Options { + if opt.Value == subject { + found = true + break + } + } + } + + if !found { + subject = "" + } + } + + var vars = map[string]interface{}{ + "Intent": intent, + "TableID": tableID, + "TableLabel": tableLabel, + "Subject": subject, + "PageTitle": title, + "Subjects": config.ContactUsChoices, + "Message": message, + "MessageRequired": messageRequired, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/mail/mail.go b/pkg/mail/mail.go index eb06d04..54c9f1a 100644 --- a/pkg/mail/mail.go +++ b/pkg/mail/mail.go @@ -77,13 +77,14 @@ func Send(msg Message) error { m.SetBody("text/plain", plaintext) m.AddAlternative("text/html", html.String()) - // Deliver. - d := gomail.NewDialer(conf.Host, conf.Port, conf.Username, conf.Password) - + // Deliver asynchronously. log.Info("mail.Send: %s (%s) to %s", msg.Subject, msg.Template, msg.To) - if err := d.DialAndSend(m); err != nil { - log.Error("mail.Send: %s", err.Error()) - } + d := gomail.NewDialer(conf.Host, conf.Port, conf.Username, conf.Password) + go func() { + if err := d.DialAndSend(m); err != nil { + log.Error("mail.Send: %s", err.Error()) + } + }() return nil } diff --git a/pkg/models/feedback.go b/pkg/models/feedback.go new file mode 100644 index 0000000..2b05c85 --- /dev/null +++ b/pkg/models/feedback.go @@ -0,0 +1,89 @@ +package models + +import ( + "strings" + "time" + + "git.kirsle.net/apps/gosocial/pkg/log" +) + +// Feedback table for Contact Us & Reporting to admins. +type Feedback struct { + ID uint64 `gorm:"primaryKey"` + UserID uint64 `gorm:"index"` // if logged-in user posted this + Acknowledged bool `gorm:"index"` // admin dashboard "read" status + Intent string + Subject string + Message string + TableName string + TableID uint64 + ReplyTo string // logged-out user may leave their email for reply + CreatedAt time.Time + UpdatedAt time.Time +} + +// GetFeedback by ID. +func GetFeedback(id uint64) (*Feedback, error) { + m := &Feedback{} + result := DB.First(&m, id) + return m, result.Error +} + +// CountUnreadFeedback gets the count of unacknowledged feedback for admins. +func CountUnreadFeedback() int64 { + query := DB.Where( + "acknowledged = ?", + false, + ) + + var count int64 + result := query.Model(&Feedback{}).Count(&count) + if result.Error != nil { + log.Error("models.CountUnreadFeedback: %s", result.Error) + } + return count +} + +// PaginateFeedback +func PaginateFeedback(acknowledged bool, intent string, pager *Pagination) ([]*Feedback, error) { + var ( + fb = []*Feedback{} + wheres = []string{} + placeholders = []interface{}{} + ) + + wheres = append(wheres, "acknowledged = ?") + placeholders = append(placeholders, acknowledged) + + if intent != "" { + wheres = append(wheres, "intent = ?") + placeholders = append(placeholders, intent) + } + + query := DB.Where( + strings.Join(wheres, " AND "), + placeholders..., + ).Order( + pager.Sort, + ) + + query.Model(&Feedback{}).Count(&pager.Total) + + result := query.Offset( + pager.GetOffset(), + ).Limit(pager.PerPage).Find(&fb) + + return fb, result.Error +} + +// CreateFeedback saves a new Feedback row to the DB. +func CreateFeedback(fb *Feedback) error { + result := DB.Create(fb) + return result.Error +} + +// Save Feedback. +func (fb *Feedback) Save() error { + result := DB.Save(fb) + return result.Error +} diff --git a/pkg/models/models.go b/pkg/models/models.go index c44c956..01a8ea3 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -15,4 +15,5 @@ func AutoMigrate() { DB.AutoMigrate(&Message{}) DB.AutoMigrate(&Friend{}) DB.AutoMigrate(&Block{}) + DB.AutoMigrate(&Feedback{}) } diff --git a/pkg/router/router.go b/pkg/router/router.go index 29cd614..b1f6dfd 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -25,6 +25,7 @@ func New() http.Handler { mux.HandleFunc("/faq", index.FAQ()) mux.HandleFunc("/tos", index.TOS()) mux.HandleFunc("/privacy", index.Privacy()) + mux.HandleFunc("/contact", index.Contact()) mux.HandleFunc("/login", account.Login()) mux.HandleFunc("/logout", account.Logout()) mux.HandleFunc("/signup", account.Signup()) @@ -57,6 +58,7 @@ func New() http.Handler { // Admin endpoints. mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard())) mux.Handle("/admin/photo/certification", middleware.AdminRequired(photo.AdminCertification())) + mux.Handle("/admin/feedback", middleware.AdminRequired(admin.Feedback())) mux.Handle("/admin/user-action", middleware.AdminRequired(admin.UserActions())) // JSON API endpoints. diff --git a/pkg/templates/template_vars.go b/pkg/templates/template_vars.go index 2b1aaa0..9472d63 100644 --- a/pkg/templates/template_vars.go +++ b/pkg/templates/template_vars.go @@ -32,6 +32,7 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { m["NavFriendRequests"] = 0 m["NavAdminNotifications"] = 0 // total count of admin notifications for nav m["NavCertificationPhotos"] = 0 // admin indicator for certification photos + m["NavAdminFeedback"] = 0 // admin indicator for unread feedback m["SessionImpersonated"] = false if r == nil { @@ -60,10 +61,16 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) { // Are we admin? if user.IsAdmin { - // Any pending certification photos? - m["NavCertificationPhotos"] = models.CountCertificationPhotosNeedingApproval() + // Any pending certification photos or feedback? + var ( + certPhotos = models.CountCertificationPhotosNeedingApproval() + feedback = models.CountUnreadFeedback() + ) + m["NavCertificationPhotos"] = certPhotos + m["NavAdminFeedback"] = feedback + + // Total notification count for admin actions. + m["NavAdminNotifications"] = certPhotos + feedback } } - - m["NavAdminNotifications"] = m["NavCertificationPhotos"] } diff --git a/web/templates/account/profile.html b/web/templates/account/profile.html index bc11020..f51f2a6 100644 --- a/web/templates/account/profile.html +++ b/web/templates/account/profile.html @@ -120,7 +120,7 @@ -
+ + {{.PageTitle}} +
++ Dear website administrators, +
+ ++ A user has posted a message to you via the Contact Us form. This message + is viewable on your admin dashboard and the details are also copied below: +
+ ++ The user's message was as follows: +
+ ++ To view this message on the admin dashboard, please visit: + {{.Data.AdminURL}} +
+ ++ This is an automated e-mail; do not reply to this message. +
+ + +{{end}} \ No newline at end of file diff --git a/web/templates/friend/friends.html b/web/templates/friend/friends.html index 11c4797..ec24bb6 100644 --- a/web/templates/friend/friends.html +++ b/web/templates/friend/friends.html @@ -72,15 +72,19 @@{{.NameOrUsername}}
+{{.Username}} diff --git a/web/templates/inbox/inbox.html b/web/templates/inbox/inbox.html index 6503c04..a4c0997 100644 --- a/web/templates/inbox/inbox.html +++ b/web/templates/inbox/inbox.html @@ -33,7 +33,20 @@ - + +