From c1268ae9b12057f2077d38c437631c755ca5fad2 Mon Sep 17 00:00:00 2001 From: Noah Date: Fri, 26 Aug 2022 19:50:33 -0700 Subject: [PATCH] Comments on Photos * Add permalink URL for photos to view their comment threads. * Commenters can Edit or Delete their own comments. * Photo owners can Delete any comment on it. * Update Privacy Policy --- pkg/controller/comment/post_comment.go | 192 +++++++++++++++++ pkg/controller/photo/site_gallery.go | 2 + pkg/controller/photo/user_gallery.go | 2 + pkg/controller/photo/view.go | 92 +++++++++ pkg/models/comment.go | 64 ++++++ pkg/models/notification.go | 5 +- pkg/router/router.go | 3 + pkg/templates/template_funcs.go | 8 + web/templates/account/dashboard.html | 37 +++- web/templates/comment/post_comment.html | 91 ++++++++ web/templates/forum/thread.html | 8 + web/templates/photo/gallery.html | 76 ++++--- web/templates/photo/permalink.html | 264 ++++++++++++++++++++++++ web/templates/privacy.html | 27 ++- 14 files changed, 830 insertions(+), 41 deletions(-) create mode 100644 pkg/controller/comment/post_comment.go create mode 100644 pkg/controller/photo/view.go create mode 100644 web/templates/comment/post_comment.html create mode 100644 web/templates/photo/permalink.html diff --git a/pkg/controller/comment/post_comment.go b/pkg/controller/comment/post_comment.go new file mode 100644 index 0000000..fd7d4c5 --- /dev/null +++ b/pkg/controller/comment/post_comment.go @@ -0,0 +1,192 @@ +package comment + +import ( + "net/http" + "net/url" + "strconv" + "strings" + + "code.nonshy.com/nonshy/website/pkg/log" + "code.nonshy.com/nonshy/website/pkg/models" + "code.nonshy.com/nonshy/website/pkg/session" + "code.nonshy.com/nonshy/website/pkg/templates" +) + +// PostComment view - for previewing or submitting your comment. +func PostComment() http.HandlerFunc { + tmpl := templates.Must("comment/post_comment.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Query params. + var ( + tableName = r.FormValue("table_name") + tableID uint64 + editCommentID = r.FormValue("edit") // edit your comment + isDelete = r.FormValue("delete") == "true" + intent = r.FormValue("intent") // preview or submit + message = r.PostFormValue("message") // comment body + comment *models.Comment // if editing a comment + fromURL = r.FormValue("next") // what page to send back to + ) + + // Parse the table ID param. + if idStr := r.FormValue("table_id"); idStr == "" { + session.FlashError(w, r, "Comment table ID required.") + templates.Redirect(w, "/") + return + } else { + if idInt, err := strconv.Atoi(idStr); err != nil { + session.FlashError(w, r, "Comment table ID invalid.") + templates.Redirect(w, "/") + return + } else { + tableID = uint64(idInt) + } + } + + // Redirect URL must be relative. + if !strings.HasPrefix(fromURL, "/") { + // Maybe it's URL encoded? + fromURL, _ = url.QueryUnescape(fromURL) + if !strings.HasPrefix(fromURL, "/") { + fromURL = "/" + } + } + + // Validate everything else. + if _, ok := models.CommentableTables[tableName]; !ok { + session.FlashError(w, r, "You can not comment on that.") + templates.Redirect(w, "/") + return + } + + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get current user: %s", err) + templates.Redirect(w, "/") + return + } + + // Who will we notify about this comment? e.g. if commenting on a photo, + // this is the user who owns the photo. + var notifyUser *models.User + switch tableName { + case "photos": + if photo, err := models.GetPhoto(tableID); err == nil { + if user, err := models.GetUser(photo.UserID); err == nil { + notifyUser = user + } else { + log.Error("Comments: couldn't get NotifyUser for photo ID %d (user ID %d): %s", + tableID, photo.UserID, err, + ) + } + } else { + log.Error("Comments: couldn't get NotifyUser for photo ID %d: %s", tableID, err) + } + } + + // Are we editing or deleting our comment? + if len(editCommentID) > 0 { + if i, err := strconv.Atoi(editCommentID); err == nil { + if found, err := models.GetComment(uint64(i)); err == nil { + comment = found + + // Verify that it is indeed OUR comment to manage: + // - If the current user posted it + // - If we are an admin + // - If we are the notifyUser for this comment (they can delete, not edit). + if currentUser.ID != comment.UserID && !currentUser.IsAdmin && + !(notifyUser != nil && currentUser.ID == notifyUser.ID && isDelete) { + templates.ForbiddenPage(w, r) + return + } + + // Initialize the form w/ the content of this message. + if r.Method == http.MethodGet { + message = comment.Message + } + + // Are we DELETING this comment? + if isDelete { + if err := comment.Delete(); err != nil { + session.FlashError(w, r, "Error deleting your commenting: %s", err) + } else { + session.Flash(w, r, "Your comment has been deleted.") + } + templates.Redirect(w, fromURL) + return + } + } else { + // Comment not found - show the Forbidden page anyway. + templates.ForbiddenPage(w, r) + return + } + } else { + templates.NotFoundPage(w, r) + return + } + } + + // Submitting the form. + if r.Method == http.MethodPost { + // Default intent is preview unless told to submit. + if intent == "submit" { + // Are we modifying an existing comment? + if comment != nil { + comment.Message = message + + if err := comment.Save(); err != nil { + session.FlashError(w, r, "Couldn't save comment: %s", err) + } else { + session.Flash(w, r, "Comment updated!") + } + templates.Redirect(w, fromURL) + return + } + + // Create the comment. + if comment, err := models.AddComment( + currentUser, + tableName, + tableID, + message, + ); err != nil { + session.FlashError(w, r, "Couldn't create comment: %s", err) + } else { + session.Flash(w, r, "Comment added!") + templates.Redirect(w, fromURL) + + // Notify the recipient of the comment. + if notifyUser != nil && notifyUser.ID != currentUser.ID { + notif := &models.Notification{ + UserID: notifyUser.ID, + AboutUser: *currentUser, + Type: models.NotificationComment, + TableName: comment.TableName, + TableID: comment.TableID, + Message: message, + Link: fromURL, + } + if err := models.CreateNotification(notif); err != nil { + log.Error("Couldn't create Comment notification: %s", err) + } + } + return + } + } + } + + var vars = map[string]interface{}{ + "Intent": intent, + "EditCommentID": editCommentID, + "Message": message, + "TableName": tableName, + "TableID": tableID, + "Next": fromURL, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/photo/site_gallery.go b/pkg/controller/photo/site_gallery.go index 09f2371..a42ce80 100644 --- a/pkg/controller/photo/site_gallery.go +++ b/pkg/controller/photo/site_gallery.go @@ -52,12 +52,14 @@ func SiteGallery() http.HandlerFunc { photoIDs = append(photoIDs, p.ID) } likeMap := models.MapLikes(currentUser, "photos", photoIDs) + commentMap := models.MapCommentCounts("photos", photoIDs) var vars = map[string]interface{}{ "IsSiteGallery": true, "Photos": photos, "UserMap": userMap, "LikeMap": likeMap, + "CommentMap": commentMap, "Pager": pager, "ViewStyle": viewStyle, } diff --git a/pkg/controller/photo/user_gallery.go b/pkg/controller/photo/user_gallery.go index 5c50bc4..47306cb 100644 --- a/pkg/controller/photo/user_gallery.go +++ b/pkg/controller/photo/user_gallery.go @@ -97,6 +97,7 @@ func UserPhotos() http.HandlerFunc { photoIDs = append(photoIDs, p.ID) } likeMap := models.MapLikes(currentUser, "photos", photoIDs) + commentMap := models.MapCommentCounts("photos", photoIDs) var vars = map[string]interface{}{ "IsOwnPhotos": currentUser.ID == user.ID, @@ -105,6 +106,7 @@ func UserPhotos() http.HandlerFunc { "PhotoCount": models.CountPhotos(user.ID), "Pager": pager, "LikeMap": likeMap, + "CommentMap": commentMap, "ViewStyle": viewStyle, "ExplicitCount": explicitCount, } diff --git a/pkg/controller/photo/view.go b/pkg/controller/photo/view.go new file mode 100644 index 0000000..69c03cb --- /dev/null +++ b/pkg/controller/photo/view.go @@ -0,0 +1,92 @@ +package photo + +import ( + "net/http" + "strconv" + + "code.nonshy.com/nonshy/website/pkg/log" + "code.nonshy.com/nonshy/website/pkg/models" + "code.nonshy.com/nonshy/website/pkg/session" + "code.nonshy.com/nonshy/website/pkg/templates" +) + +// View photo controller to see the comment thread. +func View() http.HandlerFunc { + tmpl := templates.Must("photo/permalink.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Required query param: the photo ID. + var photo *models.Photo + if idStr := r.FormValue("id"); idStr == "" { + session.FlashError(w, r, "Missing photo ID parameter.") + templates.Redirect(w, "/") + return + } else { + if idInt, err := strconv.Atoi(idStr); err != nil { + session.FlashError(w, r, "Invalid ID parameter.") + templates.Redirect(w, "/") + return + } else { + if found, err := models.GetPhoto(uint64(idInt)); err != nil { + templates.NotFoundPage(w, r) + return + } else { + photo = found + } + } + } + + // Find the photo's owner. + user, err := models.GetUser(photo.UserID) + if err != nil { + templates.NotFoundPage(w, r) + return + } + + // Load the current user in case they are viewing their own page. + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser") + } + var isOwnPhoto = currentUser.ID == user.ID + + // Is either one blocking? + if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin { + templates.NotFoundPage(w, r) + return + } + + // Is this user private and we're not friends? + var ( + areFriends = models.AreFriends(user.ID, currentUser.ID) + isPrivate = user.Visibility == models.UserVisibilityPrivate && !areFriends + ) + if isPrivate && !currentUser.IsAdmin && !isOwnPhoto { + session.FlashError(w, r, "This user's profile page and photo gallery are private.") + templates.Redirect(w, "/u/"+user.Username) + return + } + + // Get Likes information about these photos. + likeMap := models.MapLikes(currentUser, "photos", []uint64{photo.ID}) + commentMap := models.MapCommentCounts("photos", []uint64{photo.ID}) + + // Get all the comments. + comments, err := models.ListComments("photos", photo.ID) + if err != nil { + log.Error("Couldn't list comments for photo %d: %s", photo.ID, err) + } + + var vars = map[string]interface{}{ + "IsOwnPhoto": currentUser.ID == user.ID, + "User": user, + "Photo": photo, + "LikeMap": likeMap, + "CommentMap": commentMap, + "Comments": comments, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/models/comment.go b/pkg/models/comment.go index dadfb78..7f726f4 100644 --- a/pkg/models/comment.go +++ b/pkg/models/comment.go @@ -3,6 +3,7 @@ package models import ( "time" + "code.nonshy.com/nonshy/website/pkg/log" "gorm.io/gorm" ) @@ -18,6 +19,12 @@ type Comment struct { UpdatedAt time.Time } +// CommentableTables are the set of table names that allow comments (via the +// generic "/comments" URI which accepts a table_name param) +var CommentableTables = map[string]interface{}{ + "photos": nil, +} + // Preload related tables for the forum (classmethod). func (c *Comment) Preload() *gorm.DB { return DB.Preload("User.ProfilePhoto") @@ -74,6 +81,16 @@ func PaginateComments(user *User, tableName string, tableID uint64, pager *Pagin return cs, result.Error } +// ListComments returns a complete set of comments without paging. +func ListComments(tableName string, tableID uint64) ([]*Comment, error) { + var cs []*Comment + result := (&Comment{}).Preload().Where( + "table_name = ? AND table_id = ?", + tableName, tableID, + ).Order("created_at asc").Find(&cs) + return cs, result.Error +} + // Save a comment. func (c *Comment) Save() error { return DB.Save(c).Error @@ -83,3 +100,50 @@ func (c *Comment) Save() error { func (c *Comment) Delete() error { return DB.Delete(c).Error } + +type CommentCountMap map[uint64]int64 + +// MapCommentCounts collects total numbers of comments over a set of table IDs. Returns a +// map of table ID (uint64) to comment counts for each (int64). +func MapCommentCounts(tableName string, tableIDs []uint64) CommentCountMap { + var result = CommentCountMap{} + + // Initialize the result set. + for _, id := range tableIDs { + result[id] = 0 + } + + // Hold the result of the grouped count query. + type group struct { + ID uint64 + Comments int64 + } + var groups = []group{} + + // Map the counts of comments to each of these IDs. + if res := DB.Table( + "comments", + ).Select( + "table_id AS id, count(id) AS comments", + ).Where( + "table_name = ? AND table_id IN ?", + tableName, tableIDs, + ).Group("table_id").Scan(&groups); res.Error != nil { + log.Error("MapCommentCounts: count query: %s", res.Error) + } + + // Map the counts back in. + for _, row := range groups { + result[row.ID] = row.Comments + } + + return result +} + +// Get a comment count for the given table ID from the map. +func (cc CommentCountMap) Get(id uint64) int64 { + if value, ok := cc[id]; ok { + return value + } + return 0 +} diff --git a/pkg/models/notification.go b/pkg/models/notification.go index eb3d04a..dde2484 100644 --- a/pkg/models/notification.go +++ b/pkg/models/notification.go @@ -18,6 +18,7 @@ type Notification struct { TableName string // on which of your tables (photos, comments, ...) TableID uint64 Message string // text associated, e.g. copy of comment added + Link string // associated URL, e.g. for comments CreatedAt time.Time UpdatedAt time.Time } @@ -148,8 +149,8 @@ func MapNotifications(ns []*Notification) NotificationMap { "notifications.id IN ?", IDs, ).Scan(&scan) - if err != nil { - log.Error("Couldn't select photo IDs for notifications: %s", err) + if err.Error != nil { + log.Error("Couldn't select photo IDs for notifications: %s", err.Error) } // Collect and load all the photos by ID. diff --git a/pkg/router/router.go b/pkg/router/router.go index 7afc9c2..07c098b 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -9,6 +9,7 @@ import ( "code.nonshy.com/nonshy/website/pkg/controller/admin" "code.nonshy.com/nonshy/website/pkg/controller/api" "code.nonshy.com/nonshy/website/pkg/controller/block" + "code.nonshy.com/nonshy/website/pkg/controller/comment" "code.nonshy.com/nonshy/website/pkg/controller/forum" "code.nonshy.com/nonshy/website/pkg/controller/friend" "code.nonshy.com/nonshy/website/pkg/controller/inbox" @@ -41,6 +42,7 @@ func New() http.Handler { mux.Handle("/u/", middleware.LoginRequired(account.Profile())) mux.Handle("/photo/upload", middleware.LoginRequired(photo.Upload())) mux.Handle("/photo/u/", middleware.LoginRequired(photo.UserPhotos())) + mux.Handle("/photo/view", middleware.LoginRequired(photo.View())) mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit())) mux.Handle("/photo/delete", middleware.LoginRequired(photo.Delete())) mux.Handle("/photo/certification", middleware.LoginRequired(photo.Certification())) @@ -51,6 +53,7 @@ func New() http.Handler { mux.Handle("/friends/add", middleware.LoginRequired(friend.AddFriend())) mux.Handle("/users/block", middleware.LoginRequired(block.BlockUser())) mux.Handle("/users/blocked", middleware.LoginRequired(block.Blocked())) + mux.Handle("/comments", middleware.LoginRequired(comment.PostComment())) mux.Handle("/admin/unimpersonate", middleware.LoginRequired(admin.Unimpersonate())) // Certification Required. Pages that only full (verified) members can access. diff --git a/pkg/templates/template_funcs.go b/pkg/templates/template_funcs.go index 6f9efdb..b71cf47 100644 --- a/pkg/templates/template_funcs.go +++ b/pkg/templates/template_funcs.go @@ -4,6 +4,7 @@ import ( "fmt" "html/template" "net/http" + "net/url" "strings" "time" @@ -74,6 +75,13 @@ func TemplateFuncs(r *http.Request) template.FuncMap { "SubtractInt": func(a, b int) int { return a - b }, + "UrlEncode": func(values ...interface{}) string { + var result string + for _, value := range values { + result += url.QueryEscape(fmt.Sprintf("%v", value)) + } + return result + }, } } diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index 3fa07b7..be1526a 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -194,15 +194,32 @@ {{.AboutUser.Username}} liked your {{if eq .TableName "photos"}} - photo. + {{if $Body.Photo}} + photo. + {{else}} + photo. + {{end}} {{else if eq .TableName "users"}} profile page. {{else}} {{.TableName}}. {{end}} + {{else if eq .Type "comment"}} + + + {{.AboutUser.Username}} + commented on your + + {{if eq .TableName "photos"}} + photo: + {{else}} + {{.TableName}}: + {{end}} + + {{else if eq .Type "friendship_approved"}} - + {{.AboutUser.Username}} accepted your friend request! @@ -224,7 +241,7 @@ {{if .Message}} -
+
{{ToMarkdown .Message}}
{{end}} @@ -232,7 +249,15 @@ {{if $Body.Photo}}
- {{or $Body.Photo.Caption "No caption."}} + + {{if eq .Type "comment"}} + + {{else}} + {{or $Body.Photo.Caption "No caption."}} + {{end}}
{{end}} @@ -245,7 +270,9 @@ {{if $Body.PhotoID}}
- + + + {{if $Body.Photo.Caption}} {{$Body.Photo.Caption}} diff --git a/web/templates/comment/post_comment.html b/web/templates/comment/post_comment.html new file mode 100644 index 0000000..f7abddf --- /dev/null +++ b/web/templates/comment/post_comment.html @@ -0,0 +1,91 @@ +{{define "title"}} + {{if .EditCommentID}} + Edit Comment + {{else}} + New Comment + {{end}} +{{end}} +{{define "content"}} +
+
+
+
+

+ {{if .EditCommentID}} + Edit Comment + {{else}} + Add Comment + {{end}} +

+
+
+
+ +
+
+
+ +
+ +
+ + {{if and (eq .Request.Method "POST") (ne .Message "")}} + +
+ {{ToMarkdown .Message}} +
+ {{end}} + +
+ {{InputCSRF}} + + + + + +
+ + +

+ Markdown formatting supported. +

+
+ +
+ + +
+
+ +
+
+ +
+
+
+ +
+{{end}} \ No newline at end of file diff --git a/web/templates/forum/thread.html b/web/templates/forum/thread.html index 7dd921a..7c3cfdb 100644 --- a/web/templates/forum/thread.html +++ b/web/templates/forum/thread.html @@ -127,6 +127,14 @@
{{ToMarkdown .Message}} + {{if .UpdatedAt.After .CreatedAt}} +
+ + Edited {{SincePrettyCoarse .UpdatedAt}} ago + +
+ {{end}} +
diff --git a/web/templates/photo/gallery.html b/web/templates/photo/gallery.html index 8d1f862..dd41367 100644 --- a/web/templates/photo/gallery.html +++ b/web/templates/photo/gallery.html @@ -263,20 +263,29 @@ {{template "card-body" .}} - -
- {{$Like := $Root.LikeMap.Get .ID}} - + +
+
+ {{$Like := $Root.LikeMap.Get .ID}} + +
+
+ {{$Comments := $Root.CommentMap.Get .ID}} + + + {{$Comments}} Comment{{Pluralize64 $Comments}} + +
@@ -360,20 +369,29 @@ {{template "card-body" .}} - -
- {{$Like := $Root.LikeMap.Get .ID}} - + +
+
+ {{$Like := $Root.LikeMap.Get .ID}} + +
+
+ {{$Comments := $Root.CommentMap.Get .ID}} + + + {{$Comments}} Comment{{Pluralize64 $Comments}} + +
@@ -385,7 +403,7 @@ {{if not $Root.IsOwnPhotos}} - Report + Report {{end}} diff --git a/web/templates/photo/permalink.html b/web/templates/photo/permalink.html new file mode 100644 index 0000000..a1aa4d8 --- /dev/null +++ b/web/templates/photo/permalink.html @@ -0,0 +1,264 @@ +{{define "title"}}Upload a Photo{{end}} +{{define "content"}} +
+
+
+
+

+ + + + {{or .Photo.Caption "Photo"}} +

+
+
+
+ + {{ $Root := . }} + {{ $User := .CurrentUser }} + {{ $Comments := .CommentMap.Get .Photo.ID }} + +
+ +
+ +
+ + +
+
+
+
+
+
+ {{if gt .User.ProfilePhoto.ID 0}} + + {{else}} + + {{end}} +
+
+ +
+ + {{if eq .Photo.Visibility "friends"}} + + {{else if eq .Photo.Visibility "private"}} + + {{else}} + + {{end}} + +
+
+
+
+ +
+
+ +
+
+ +
+ {{if .Photo.Caption}} + {{.Photo.Caption}} + {{else}}No caption{{end}} + + +
+
+ {{$Like := .LikeMap.Get .Photo.ID}} + +
+ +
+ + +
+ + {{if or .IsOwnPhoto .CurrentUser.IsAdmin}} + + + {{end}} + + + {{if not .IsOwnPhoto}} + + {{end}} +
+
+ +
+ + +
+
+

+ + {{$Comments}} Comment{{Pluralize64 $Comments}} +

+
+ +
+
+ {{InputCSRF}} + + + + +
+ + +

+ Markdown formatting supported. +

+
+ +
+ + + +
+
+ +
+ + {{if eq $Comments 0}} +

+ There are no comments yet. +

+ {{else}} + {{range .Comments}} + + {{end}} + {{end}} +
+
+
+ +
+{{end}} \ No newline at end of file diff --git a/web/templates/privacy.html b/web/templates/privacy.html index c302852..52a18c8 100644 --- a/web/templates/privacy.html +++ b/web/templates/privacy.html @@ -25,7 +25,7 @@

- This page was last updated on August 15, 2022. + This page was last updated on August 26, 2022.

@@ -43,6 +43,21 @@

    +
  • + You may mark your entire profile as "Private" which limits some of the contact you + may receive: +
      +
    • + Only users you have approved as a friend can see your profile and your + photo gallery. +
    • +
    • + Your photos will never appear on the Site Gallery - not + even to your friends. They will only see your photos by visiting your + profile page directly. +
    • +
    +
  • Profile photos have visibility settings including Public, Friends-only or Private:
      @@ -73,10 +88,12 @@

      When you are uploading or editing a photo, there is a checkbox labeled "Gallery" where you - can opt your photo in (or out) of the Site Gallery. Only public photos will - ever appear on the Site Gallery (never private or friends-only photos). You are also able to - exclude a public photo from the Site Gallery by unchecking the "Gallery" box on that - photo. + can opt your photo in (or out) of the Site Gallery. Only your public photos + will appear on the Site Gallery by default; your friends-only photos may + appear there for people you approved as a friend, or your private photos to people for whom + you have granted access. You are also able to exclude a photo from the Site Gallery + by unchecking the "Gallery" box on that photo -- then it will only be viewable on your own + profile page, given its other permissions (friends/private).

      Deletion of User Data