diff --git a/pkg/controller/account/dashboard.go b/pkg/controller/account/dashboard.go index 68e8869..74c2b54 100644 --- a/pkg/controller/account/dashboard.go +++ b/pkg/controller/account/dashboard.go @@ -50,7 +50,7 @@ func Dashboard() http.HandlerFunc { pager := &models.Pagination{ Page: 1, PerPage: config.PageSizeDashboardNotifications, - Sort: "created_at desc", + Sort: "read, created_at desc", } pager.ParsePage(r) notifs, err := models.PaginateNotifications(currentUser, nf, pager) diff --git a/pkg/controller/photo/batch_edit.go b/pkg/controller/photo/batch_edit.go new file mode 100644 index 0000000..0229a43 --- /dev/null +++ b/pkg/controller/photo/batch_edit.go @@ -0,0 +1,244 @@ +package photo + +import ( + "fmt" + "net/http" + "strconv" + + "code.nonshy.com/nonshy/website/pkg/chat" + "code.nonshy.com/nonshy/website/pkg/log" + "code.nonshy.com/nonshy/website/pkg/models" + pphoto "code.nonshy.com/nonshy/website/pkg/photo" + "code.nonshy.com/nonshy/website/pkg/session" + "code.nonshy.com/nonshy/website/pkg/templates" +) + +// BatchEdit controller (/photo/batch-edit?id=N) to change properties about your picture. +func BatchEdit() http.HandlerFunc { + // Reuse the upload page but with an EditPhoto variable. + tmpl := templates.Must("photo/batch_edit.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var ( + // Form params + intent = r.FormValue("intent") + photoIDs []uint64 + ) + + // Collect the photo ID params. + if value, ok := r.Form["id"]; ok { + for _, idStr := range value { + if photoID, err := strconv.Atoi(idStr); err == nil { + photoIDs = append(photoIDs, uint64(photoID)) + } else { + log.Error("parsing photo ID %s: %s", idStr, err) + } + } + } + + log.Error("photoIDs: %+v", photoIDs) + + // Validation. + if len(photoIDs) == 0 || len(photoIDs) > 100 { + session.FlashError(w, r, "Invalid number of photo IDs.") + templates.Redirect(w, "/") + return + } + + // Find these photos by ID. + photos, err := models.GetPhotos(photoIDs) + if err != nil { + templates.NotFoundPage(w, r) + return + } + + // Load the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser") + templates.Redirect(w, "/") + return + } + + // In case an admin is editing this photo: remember the HTTP request current user, + // before the currentUser may be set to the photo's owner below. + // var requestUser = currentUser + + // Validate permission to edit all of these photos. + var ( + ownerIDs []uint64 + mapPhotos = map[uint64]*models.Photo{} + ) + for _, photo := range photos { + mapPhotos[photo.ID] = photo + + if !photo.CanBeEditedBy(currentUser) { + templates.ForbiddenPage(w, r) + return + } + + ownerIDs = append(ownerIDs, photo.UserID) + } + + // Load the photo owners. + var ( + owners, _ = models.MapUsers(currentUser, ownerIDs) + wasShy = map[uint64]bool{} // record if this change may make them shy + redirectURI = "/" // go first owner's gallery + + // Are any of them a user's profile photo? (map userID->true) so we know + // who to unlink the picture from first and avoid a postgres error. + wasUserProfilePicture = map[uint64]bool{} + ) + for _, user := range owners { + redirectURI = fmt.Sprintf("/u/%s/photos", user.Username) + wasShy[user.ID] = user.IsShy() + + // Check if this user's profile ID is being deleted. + if user.ProfilePhotoID != nil { + if _, ok := mapPhotos[*user.ProfilePhotoID]; ok { + wasUserProfilePicture[user.ID] = true + } + } + } + + // Confirm batch deletion or edit. + if r.Method == http.MethodPost { + + confirm := r.PostFormValue("confirm") == "true" + if !confirm { + session.FlashError(w, r, "Confirm you want to delete this photo.") + templates.Redirect(w, redirectURI) + return + } + + // Which intent are they executing on? + switch intent { + case "delete": + batchDeletePhotos(w, r, currentUser, photos, wasUserProfilePicture, owners, redirectURI) + case "visibility": + batchUpdateVisibility(w, r, currentUser, photos, owners) + default: + session.FlashError(w, r, "Unknown intent") + } + + // Maybe kick them from chat if this deletion makes them into a Shy Account. + for _, user := range owners { + user.FlushCaches() + if !wasShy[user.ID] && user.IsShy() { + if _, err := chat.MaybeDisconnectUser(user); err != nil { + log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err) + } + } + } + + // Return the user to their gallery. + templates.Redirect(w, redirectURI) + return + } + + var vars = map[string]interface{}{ + "Intent": intent, + "Photos": photos, + } + + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} + +// Batch DELETE executive handler. +func batchDeletePhotos( + w http.ResponseWriter, + r *http.Request, + currentUser *models.User, + photos map[uint64]*models.Photo, + wasUserProfilePicture map[uint64]bool, + owners map[uint64]*models.User, + redirectURI string, +) { + // Delete all the photos. + for _, photo := range photos { + + // Was this someone's profile picture ID? + if wasUserProfilePicture[photo.UserID] { + log.Debug("Delete Photo: was the user's profile photo, unset ProfilePhotoID") + if owner, ok := owners[photo.UserID]; ok { + if err := owner.RemoveProfilePhoto(); err != nil { + session.FlashError(w, r, "Error unsetting your current profile photo: %s", err) + templates.Redirect(w, redirectURI) + return + } + } + } + + // Remove the images from disk. + for _, filename := range []string{ + photo.Filename, + photo.CroppedFilename, + } { + if len(filename) > 0 { + if err := pphoto.Delete(filename); err != nil { + log.Error("Delete Photo: couldn't remove file from disk: %s: %s", filename, err) + } + } + } + + // Take back notifications on it. + models.RemoveNotification("photos", photo.ID) + + if err := photo.Delete(); err != nil { + session.FlashError(w, r, "Couldn't delete photo: %s", err) + templates.Redirect(w, redirectURI) + return + } + + // Log the change. + if owner, ok := owners[photo.UserID]; ok { + models.LogDeleted(owner, currentUser, "photos", photo.ID, "Deleted the photo.", photo) + } + } + + session.Flash(w, r, "%d photo(s) deleted!", len(photos)) +} + +// Batch DELETE executive handler. +func batchUpdateVisibility( + w http.ResponseWriter, + r *http.Request, + currentUser *models.User, + photos map[uint64]*models.Photo, + owners map[uint64]*models.User, +) { + // Visibility setting. + visibility := r.PostFormValue("visibility") + + // Delete all the photos. + for _, photo := range photos { + + // Diff for the ChangeLog. + diffs := []models.FieldDiff{ + models.NewFieldDiff("Visibility", photo.Visibility, visibility), + } + + photo.Visibility = models.PhotoVisibility(visibility) + + // If going private, take back notifications on it. + if photo.Visibility == models.PhotoPrivate { + models.RemoveNotification("photos", photo.ID) + } + + if err := photo.Save(); err != nil { + session.FlashError(w, r, "Error saving photo #%d: %s", photo.ID, err) + } + + // Log the change. + if owner, ok := owners[photo.UserID]; ok { + // Log the change. + models.LogUpdated(owner, currentUser, "photos", photo.ID, "Updated the photo's settings.", diffs) + } + } + + session.Flash(w, r, "%d photo(s) updated!", len(photos)) +} diff --git a/pkg/controller/photo/edit_delete.go b/pkg/controller/photo/edit_delete.go index d1a23ee..994bc04 100644 --- a/pkg/controller/photo/edit_delete.go +++ b/pkg/controller/photo/edit_delete.go @@ -246,127 +246,10 @@ func Edit() http.HandlerFunc { } // Delete controller (/photo/Delete?id=N) to change properties about your picture. +// +// DEPRECATED: send them to the batch-edit endpoint. func Delete() http.HandlerFunc { - // Reuse the upload page but with an EditPhoto variable. - tmpl := templates.Must("photo/delete.html") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Query params. - photoID, err := strconv.Atoi(r.FormValue("id")) - if err != nil { - log.Error("photo.Delete: failed to parse `id` param (%s) as int: %s", r.FormValue("id"), err) - session.FlashError(w, r, "Photo 'id' parameter required.") - templates.Redirect(w, "/") - return - } - - // Page to redirect to in case of errors. - redirect := fmt.Sprintf("%s?id=%d", r.URL.Path, photoID) - - // Find this photo by ID. - photo, err := models.GetPhoto(uint64(photoID)) - if err != nil { - templates.NotFoundPage(w, r) - return - } - - // Load the current user. - currentUser, err := session.CurrentUser(r) - if err != nil { - session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser") - templates.Redirect(w, "/") - return - } - - // In case an admin is editing this photo: remember the HTTP request current user, - // before the currentUser may be set to the photo's owner below. - var requestUser = currentUser - - // Do we have permission for this photo? - if photo.UserID != currentUser.ID { - if !currentUser.HasAdminScope(config.ScopePhotoModerator) { - templates.ForbiddenPage(w, r) - return - } - - // Find the owner of this photo and assume currentUser is them for the remainder - // of this controller. - if user, err := models.GetUser(photo.UserID); err != nil { - session.FlashError(w, r, "Couldn't get the owner User for this photo!") - templates.Redirect(w, "/") - return - } else { - currentUser = user - } - } - - // Confirm deletion? - if r.Method == http.MethodPost { - // Record if this change is going to make them a Shy Account. - var wasShy = currentUser.IsShy() - - confirm := r.PostFormValue("confirm") == "true" - if !confirm { - session.FlashError(w, r, "Confirm you want to delete this photo.") - templates.Redirect(w, redirect) - return - } - - // Was this our profile picture? - if currentUser.ProfilePhotoID != nil && *currentUser.ProfilePhotoID == photo.ID { - log.Debug("Delete Photo: was the user's profile photo, unset ProfilePhotoID") - if err := currentUser.RemoveProfilePhoto(); err != nil { - session.FlashError(w, r, "Error unsetting your current profile photo: %s", err) - templates.Redirect(w, redirect) - return - } - } - - // Remove the images from disk. - for _, filename := range []string{ - photo.Filename, - photo.CroppedFilename, - } { - if len(filename) > 0 { - if err := pphoto.Delete(filename); err != nil { - log.Error("Delete Photo: couldn't remove file from disk: %s: %s", filename, err) - } - } - } - - // Take back notifications on it. - models.RemoveNotification("photos", photo.ID) - - if err := photo.Delete(); err != nil { - session.FlashError(w, r, "Couldn't delete photo: %s", err) - templates.Redirect(w, redirect) - return - } - - // Log the change. - models.LogDeleted(currentUser, requestUser, "photos", photo.ID, "Deleted the photo.", photo) - - session.Flash(w, r, "Photo deleted!") - - // Maybe kick them from chat if this deletion makes them into a Shy Account. - currentUser.FlushCaches() - if !wasShy && currentUser.IsShy() { - if _, err := chat.MaybeDisconnectUser(currentUser); err != nil { - log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err) - } - } - - // Return the user to their gallery. - templates.Redirect(w, "/u/"+currentUser.Username+"/photos") - return - } - - var vars = map[string]interface{}{ - "Photo": photo, - } - - if err := tmpl.Execute(w, r, vars); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + templates.Redirect(w, fmt.Sprintf("/photo/batch-edit?intent=delete&id=%s", r.FormValue("id"))) }) } diff --git a/pkg/models/photo.go b/pkg/models/photo.go index dc07a1f..569662e 100644 --- a/pkg/models/photo.go +++ b/pkg/models/photo.go @@ -108,6 +108,17 @@ func GetPhotos(IDs []uint64) (map[uint64]*Photo, error) { return mp, result.Error } +// CanBeEditedBy checks whether a photo can be edited by the current user. +// +// Admins with PhotoModerator scope can always edit. +func (p *Photo) CanBeEditedBy(currentUser *User) bool { + if currentUser.HasAdminScope(config.ScopePhotoModerator) { + return true + } + + return p.UserID == currentUser.ID +} + // CanBeSeenBy checks whether a photo can be seen by the current user. // // An admin user with omni photo view permission can always see the photo. diff --git a/pkg/router/router.go b/pkg/router/router.go index e39b4e5..65c957f 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -63,6 +63,7 @@ func New() http.Handler { mux.Handle("GET /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/batch-edit", middleware.LoginRequired(photo.BatchEdit())) mux.Handle("/photo/certification", middleware.LoginRequired(photo.Certification())) mux.Handle("GET /photo/private", middleware.LoginRequired(photo.Private())) mux.Handle("/photo/private/share", middleware.LoginRequired(photo.Share())) diff --git a/web/templates/errors/certification_required.html b/web/templates/errors/certification_required.html index 7288ff9..ab8c8c6 100644 --- a/web/templates/errors/certification_required.html +++ b/web/templates/errors/certification_required.html @@ -4,10 +4,33 @@

Certification Required

+ {{if and .CurrentUser.Certified (not .CurrentUser.ProfilePhoto.ID)}} +

You are just missing a default profile photo!

+ {{end}}
+ + {{if and .CurrentUser.Certified (not .CurrentUser.ProfilePhoto.ID)}} +
+

+ Notice: your Certification Photo is OK, you are just missing a profile picture! +

+ +

+ To maintain your certified status on this website, you are required to keep a + default profile picture set on your account at all times. +

+ +

+ Please visit your Photo Gallery for instructions + to set one of your existing photos as your default, or + upload a new profile picture. +

+
+ {{end}} +

Certification Required

diff --git a/web/templates/photo/batch_edit.html b/web/templates/photo/batch_edit.html new file mode 100644 index 0000000..cb7bab2 --- /dev/null +++ b/web/templates/photo/batch_edit.html @@ -0,0 +1,169 @@ +{{define "title"}}Delete Photo{{end}} +{{define "content"}} +

+ + +
+
+
+
+
+

+ {{if eq .Intent "delete"}} + + Delete {{len .Photos}} Photo{{Pluralize (len .Photos)}} + {{else if eq .Intent "visibility"}} + + Edit Visibility + {{else}} + Batch Edit Photos + {{end}} +

+
+
+
+ {{InputCSRF}} + + + + + {{if eq .Intent "visibility"}} +

+ You may use this page to set all ({{len .Photos}}) photo{{if ge (len .Photos) 2}}s'{{end}} + visibility setting. +

+ +
+ +
+ +

+ This photo will appear on your profile page and can be seen by any + logged-in user account. It may also appear on the site-wide Photo + Gallery if that option is enabled, below. +

+
+
+ +

+ Only users you have accepted as a friend can see this photo on your + profile page and on the site-wide Photo Gallery if that option is + enabled, below. +

+
+
+ +

+ This photo is visible only to you and to users for whom you have + granted access + (manage grants ). +

+
+ +
+ + Reminder: There are risks inherent with sharing + pictures on the Internet, and {{PrettyTitle}} can't guarantee that another member of the site + won't download and possibly redistribute your photos. You may mark your picture as "Friends only" + or "Private" to limit who on the website will see it, but anybody who can see it could potentially + save it to their computer. Learn more +
+ +
+ {{end}} + + +
+ {{range .Photos}} +
+ +
+ + {{if HasSuffix .Filename ".mp4"}} + + {{else}} + + {{end}} +
+
+ {{end}} +
+ +
+ Are you sure you want to + {{if eq .Intent "delete"}} + delete + {{else if eq .Intent "visibility"}} + update the visibility of + {{else}} + update + {{end}} + + {{if ge (len .Photos) 2 -}} + these {{len .Photos}} photos? + {{- else -}} + this photo? + {{- end}} +
+
+ {{if eq .Intent "delete"}} + + {{else}} + + {{end}} + +
+
+
+
+
+
+
+ +
+{{end}} diff --git a/web/templates/photo/gallery.html b/web/templates/photo/gallery.html index a7fb83c..c6073b1 100644 --- a/web/templates/photo/gallery.html +++ b/web/templates/photo/gallery.html @@ -73,6 +73,11 @@ {{define "card-footer"}} + Edit @@ -490,6 +495,9 @@ {{SimplePager .Pager}} + +
+ {{if eq .ViewStyle "full"}} {{range .Photos}} @@ -753,22 +761,117 @@ {{SimplePager .Pager}} - - {{if .CurrentUser.HasAdminScope "admin.changelog"}} -
- - - {{if .User}}User{{else}}Site{{end}} Gallery change log - + + {{if or .IsOwnPhotos (.CurrentUser.HasAdminScope "social.moderator.photo")}} +
+
+
+
+ + +
+
+ +
+ + + + + +
+
- {{end}} +
-
+ {{end}} + + {{if .CurrentUser.HasAdminScope "admin.changelog"}} +
+ + + {{if .User}}User{{else}}Site{{end}} Gallery change log + +
+ {{end}} + +