From 9d6c299fddad3264f1db17cd2d7eb2a3d2e73163 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Wed, 25 Sep 2024 22:46:33 -0700 Subject: [PATCH] Photo View Counters --- pkg/config/config.go | 6 +++ pkg/controller/api/photo.go | 70 ++++++++++++++++++++++++++++ pkg/controller/photo/site_gallery.go | 1 + pkg/controller/photo/user_gallery.go | 1 + pkg/controller/photo/view.go | 43 ++++++----------- pkg/models/photo.go | 56 ++++++++++++++++++++++ pkg/router/router.go | 1 + web/templates/photo/gallery.html | 33 ++++++++++++- web/templates/photo/permalink.html | 10 +++- 9 files changed, 189 insertions(+), 32 deletions(-) create mode 100644 pkg/controller/api/photo.go diff --git a/pkg/config/config.go b/pkg/config/config.go index b5d5b27..04c1ac3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -121,6 +121,12 @@ const ( // pictures can be posted per day. SiteGalleryRateLimitMax = 5 SiteGalleryRateLimitInterval = 24 * time.Hour + + // Only ++ the Views count per user per photo within a small + // window of time - if a user keeps reloading the same photo + // rapidly it does not increment the view counter more. + PhotoViewDebounceRedisKey = "debounce-view/user=%d/photoid=%d" + PhotoViewDebounceCooldown = 1 * time.Hour ) // Forum settings diff --git a/pkg/controller/api/photo.go b/pkg/controller/api/photo.go new file mode 100644 index 0000000..c4bd91e --- /dev/null +++ b/pkg/controller/api/photo.go @@ -0,0 +1,70 @@ +package api + +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" +) + +// ViewPhoto API pings a view count on a photo, e.g. from the lightbox modal. +func ViewPhoto() http.HandlerFunc { + // Response JSON schema. + type Response struct { + OK bool `json:"OK"` + Error string `json:"error,omitempty"` + Likes int64 `json:"likes"` + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + SendJSON(w, http.StatusBadRequest, Response{ + Error: "Couldn't get current user!", + }) + return + } + + // Photo ID from path parameter. + var photoID uint64 + if id, err := strconv.Atoi(r.PathValue("photo_id")); err == nil && id > 0 { + photoID = uint64(id) + } else { + SendJSON(w, http.StatusBadRequest, Response{ + Error: "Invalid photo ID", + }) + return + } + + // Find this photo. + photo, err := models.GetPhoto(photoID) + if err != nil { + SendJSON(w, http.StatusNotFound, Response{ + Error: "Photo Not Found", + }) + return + } + + // Check permission to have seen this photo. + if ok, err := photo.CanBeSeenBy(currentUser); !ok { + log.Error("Photo %d can't be seen by %s: %s", photo.ID, currentUser.Username, err) + SendJSON(w, http.StatusNotFound, Response{ + Error: "Photo Not Found", + }) + return + } + + // Mark a view. + if err := photo.View(currentUser.ID); err != nil { + log.Error("Update photo(%d) views: %s", photo.ID, err) + } + + // Send success response. + SendJSON(w, http.StatusOK, Response{ + OK: true, + }) + }) +} diff --git a/pkg/controller/photo/site_gallery.go b/pkg/controller/photo/site_gallery.go index 741ceeb..d653b44 100644 --- a/pkg/controller/photo/site_gallery.go +++ b/pkg/controller/photo/site_gallery.go @@ -20,6 +20,7 @@ func SiteGallery() http.HandlerFunc { "created_at asc", "like_count desc", "comment_count desc", + "views desc", } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/controller/photo/user_gallery.go b/pkg/controller/photo/user_gallery.go index 47004f0..5e1d39b 100644 --- a/pkg/controller/photo/user_gallery.go +++ b/pkg/controller/photo/user_gallery.go @@ -21,6 +21,7 @@ func UserPhotos() http.HandlerFunc { "created_at asc", "like_count desc", "comment_count desc", + "views desc", } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/controller/photo/view.go b/pkg/controller/photo/view.go index a4ea245..c34af78 100644 --- a/pkg/controller/photo/view.go +++ b/pkg/controller/photo/view.go @@ -35,6 +35,12 @@ func View() http.HandlerFunc { } } + // 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") + } + // Find the photo's owner. user, err := models.GetUser(photo.UserID) if err != nil { @@ -42,34 +48,10 @@ func View() http.HandlerFunc { 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 - } - - // Is this a private photo and are we allowed to see? - isGranted := models.IsPrivateUnlocked(user.ID, currentUser.ID) - if photo.Visibility == models.PhotoPrivate && !isGranted && !isOwnPhoto && !currentUser.IsAdmin { - templates.NotFoundPage(w, r) + if ok, err := photo.CanBeSeenBy(currentUser); !ok { + log.Error("Photo %d can't be seen by %s: %s", photo.ID, currentUser.Username, err) + session.FlashError(w, r, "Photo Not Found") + templates.Redirect(w, "/") return } @@ -102,6 +84,11 @@ func View() http.HandlerFunc { // Is the current user subscribed to notifications on this thread? _, isSubscribed := models.IsSubscribed(currentUser, "photos", photo.ID) + // Mark this photo as "Viewed" by the user. + if err := photo.View(currentUser.ID); err != nil { + log.Error("Update photo(%d) views: %s", photo.ID, err) + } + var vars = map[string]interface{}{ "IsOwnPhoto": currentUser.ID == user.ID, "User": user, diff --git a/pkg/models/photo.go b/pkg/models/photo.go index bc47358..db854a3 100644 --- a/pkg/models/photo.go +++ b/pkg/models/photo.go @@ -8,6 +8,7 @@ import ( "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" + "code.nonshy.com/nonshy/website/pkg/redis" "gorm.io/gorm" ) @@ -27,6 +28,7 @@ type Photo struct { Pinned bool `gorm:"index"` // user pins it to the front of their gallery LikeCount int64 `gorm:"index"` // cache of 'likes' count CommentCount int64 `gorm:"index"` // cache of comments count + Views uint64 `gorm:"index"` // view count CreatedAt time.Time `gorm:"index"` UpdatedAt time.Time } @@ -105,6 +107,41 @@ func GetPhotos(IDs []uint64) (map[uint64]*Photo, error) { return mp, result.Error } +// CanBeSeenBy checks whether a photo can be seen by the current user. +// +// Note: this function incurs several DB queries to look up the photo's owner and block lists. +func (p *Photo) CanBeSeenBy(currentUser *User) (bool, error) { + // Find the photo's owner. + user, err := GetUser(p.UserID) + if err != nil { + return false, err + } + + var isOwnPhoto = currentUser.ID == user.ID + + // Is either one blocking? + if !currentUser.IsAdmin && IsBlocking(currentUser.ID, user.ID) { + return false, errors.New("is blocking") + } + + // Is this user private and we're not friends? + var ( + areFriends = AreFriends(user.ID, currentUser.ID) + isPrivate = user.Visibility == UserVisibilityPrivate && !areFriends + ) + if isPrivate && !currentUser.IsAdmin && !isOwnPhoto { + return false, errors.New("user is private and we aren't friends") + } + + // Is this a private photo and are we allowed to see? + isGranted := IsPrivateUnlocked(user.ID, currentUser.ID) + if p.Visibility == PhotoPrivate && !isGranted && !isOwnPhoto && !currentUser.IsAdmin { + return false, errors.New("photo is private") + } + + return true, nil +} + // UserGallery configuration for filtering gallery pages. type UserGallery struct { Explicit string // "", "true", "false" @@ -154,6 +191,25 @@ func PaginateUserPhotos(userID uint64, conf UserGallery, pager *Pagination) ([]* return p, result.Error } +// View a photo, incrementing its Views count but not its UpdatedAt. +// Debounced with a Redis key. +func (p *Photo) View(userID uint64) error { + // Debounce this. + var redisKey = fmt.Sprintf(config.PhotoViewDebounceRedisKey, userID, p.ID) + if redis.Exists(redisKey) { + return nil + } + redis.Set(redisKey, nil, config.PhotoViewDebounceCooldown) + + return DB.Model(&Photo{}).Where( + "id = ?", + p.ID, + ).Updates(map[string]interface{}{ + "views": p.Views + 1, + "updated_at": p.UpdatedAt, + }).Error +} + // CountPhotos returns the total number of photos on a user's account. func CountPhotos(userID uint64) int64 { var count int64 diff --git a/pkg/router/router.go b/pkg/router/router.go index 7561b10..b036f22 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -119,6 +119,7 @@ func New() http.Handler { mux.Handle("GET /v1/web-push/unregister", middleware.LoginRequired(webpush.UnregisterAll())) mux.Handle("POST /v1/likes", middleware.LoginRequired(api.Likes())) mux.Handle("GET /v1/likes/users", middleware.LoginRequired(api.WhoLikes())) + mux.Handle("POST /v1/photo/{photo_id}/view", middleware.LoginRequired(api.ViewPhoto())) mux.Handle("POST /v1/notifications/read", middleware.LoginRequired(api.ReadNotification())) mux.Handle("POST /v1/notifications/delete", middleware.LoginRequired(api.ClearNotification())) mux.Handle("POST /v1/photos/mark-explicit", middleware.LoginRequired(api.MarkPhotoExplicit())) diff --git a/web/templates/photo/gallery.html b/web/templates/photo/gallery.html index 105599c..1fa19bb 100644 --- a/web/templates/photo/gallery.html +++ b/web/templates/photo/gallery.html @@ -17,6 +17,12 @@ {{define "card-body"}}
Uploaded {{.CreatedAt.Format "Jan _2 2006 15:04:05"}} + {{if .Views}} + + + {{.Views}} + + {{end}}
{{if .Pinned}} @@ -416,6 +422,7 @@ +
@@ -542,7 +549,7 @@ {{else}} - {{else}} - response.json()) + .then(data => { + if (data.StatusCode !== 200) { + window.alert(data.data.error); + return; + } + }).catch(window.alert); + } + document.querySelectorAll(".js-modal-trigger").forEach(node => { let $img = node.getElementsByTagName("img"), + photoID = node.dataset.photoId, altText = $img[0] != undefined ? $img[0].alt : ''; node.addEventListener("click", (e) => { e.preventDefault(); setModalImage(node.dataset.url, altText); $modal.classList.add("is-active"); + + // Log a view of this photo. + markImageViewed(photoID); }) }); }); diff --git a/web/templates/photo/permalink.html b/web/templates/photo/permalink.html index 52c5613..1e99ec4 100644 --- a/web/templates/photo/permalink.html +++ b/web/templates/photo/permalink.html @@ -100,6 +100,12 @@
+ + + + + {{.Photo.Views}} view{{PluralizeU64 .Photo.Views}} + Uploaded {{.Photo.CreatedAt.Format "Jan _2 2006 15:04:05"}}
@@ -162,7 +168,7 @@ {{if not .IsOwnPhoto}} -
+ - +
{{end}}