Batch Edit/Delete Photos + Misc Fixes

Certification Required page:

* Show helpful advice if the reason for the page is only that the user had
  deleted their default profile pic, but their account was certified.

Batch Photo Delete & Visibility:

* On user galleries, owners and admins can batch Delete or Set Visibility on
  many photos at once. Checkboxes appear in the edit/delete row of each photo,
  and bulk actions appear at the bottom of the page along with select/unselect
  all boxes.
* Deprecated the old /photo/delete endpoint: it now redirects to the batch
  delete page with the one photo ID.

Misc Changes:

* Notifications now sort unread to the top always.
This commit is contained in:
Noah Petherbridge 2024-10-04 21:17:20 -07:00
parent cbdabe791e
commit 8078ff8755
8 changed files with 564 additions and 130 deletions

View File

@ -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)

View File

@ -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))
}

View File

@ -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")))
})
}

View File

@ -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.

View File

@ -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()))

View File

@ -4,10 +4,33 @@
<div class="hero-body">
<div class="container">
<h1 class="title">Certification Required</h1>
{{if and .CurrentUser.Certified (not .CurrentUser.ProfilePhoto.ID)}}
<h2 class="subtitle">You are just missing a default profile photo!</h2>
{{end}}
</div>
</div>
</section>
<!-- Just missing a cert photo? -->
{{if and .CurrentUser.Certified (not .CurrentUser.ProfilePhoto.ID)}}
<div class="notification is-success is-light content">
<p>
<strong>Notice:</strong> your Certification Photo is OK, you are just missing a profile picture!
</p>
<p>
To maintain your <strong>certified</strong> status on this website, you are required to keep a
<strong>default profile picture</strong> set on your account at all times.
</p>
<p>
Please <a href="/u/{{.CurrentUser.Username}}/photos">visit your Photo Gallery</a> for instructions
to set one of your existing photos as your default, or
<a href="/photo/upload?intent=profile_pic">upload a new profile picture.</a>
</p>
</div>
{{end}}
<div class="block content p-4 mb-0">
<h1>Certification Required</h1>
<p>

View File

@ -0,0 +1,169 @@
{{define "title"}}Delete Photo{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-link is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
{{if eq .Intent "delete"}}
<i class="fa fa-trash mr-2"></i>
Delete {{len .Photos}} Photo{{Pluralize (len .Photos)}}
{{else if eq .Intent "visibility"}}
<i class="fa fa-eye mr-2"></i>
Edit Visibility
{{else}}
Batch Edit Photos
{{end}}
</h1>
</div>
</div>
</section>
<div class="block p-4">
<div class="level">
<div class="level-item">
<div class="card" style="max-width: 800px">
<header class="card-header {{if eq .Intent "delete"}}has-background-danger{{else}}has-background-link{{end}}">
<p class="card-header-title has-text-light">
{{if eq .Intent "delete"}}
<span class="icon"><i class="fa fa-trash"></i></span>
Delete {{len .Photos}} Photo{{Pluralize (len .Photos)}}
{{else if eq .Intent "visibility"}}
<span class="icon"><i class="fa fa-eye mr-2"></i></span>
Edit Visibility
{{else}}
Batch Edit Photos
{{end}}
</p>
</header>
<div class="card-content">
<form method="POST" action="/photo/batch-edit">
{{InputCSRF}}
<input type="hidden" name="intent" value="{{.Intent}}">
<input type="hidden" name="confirm" value="true">
<!-- Bulk Visibility Settings -->
{{if eq .Intent "visibility"}}
<p>
You may use this page to set <strong>all ({{len .Photos}}) photo{{if ge (len .Photos) 2}}s'{{end}}</strong>
visibility setting.
</p>
<!-- TODO: copy/pasted block from the Upload page -->
<div class="field">
<label class="label">Photo Visibility</label>
<div>
<label class="radio">
<input type="radio"
name="visibility"
value="public"
{{if or (not .EditPhoto) (eq .EditPhoto.Visibility "public")}}checked{{end}}>
<strong class="has-text-link ml-1">
<span>Public <small>(members only)</small></span>
<span class="icon"><i class="fa fa-eye"></i></span>
</strong>
</label>
<p class="help">
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.
</p>
</div>
<div>
<label class="radio">
<input type="radio"
name="visibility"
value="friends"
{{if eq .EditPhoto.Visibility "friends"}}checked{{end}}>
<strong class="has-text-warning ml-1">
<span>Friends only</span>
<span class="icon"><i class="fa fa-user-group"></i></span>
</strong>
</label>
<p class="help">
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.
</p>
</div>
<div>
<label class="radio">
<input type="radio"
name="visibility"
value="private"
{{if eq .EditPhoto.Visibility "private"}}checked{{end}}>
<strong class="has-text-private ml-1">
<span>Private</span>
<span class="icon"><i class="fa fa-lock"></i></span>
</strong>
</label>
<p class="help">
This photo is visible only to you and to users for whom you have
granted access
(<a href="/photo/private" target="_blank" class="has-text-private">manage grants <i class="fa fa-external-link"></i></a>).
</p>
</div>
<div class="has-text-warning is-size-7 mt-4">
<i class="fa fa-info-circle mr-1"></i>
<strong class="has-text-warning">Reminder:</strong> 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 <em>can</em> see it could potentially
save it to their computer. <a href="/faq#downloading" target="_blank">Learn more <i class="fa fa-external-link"></i></a>
</div>
</div>
{{end}}
<!-- Show range of photos on all updates -->
<div class="columns is-mobile is-multiline">
{{range .Photos}}
<div class="column is-half">
<input type="hidden" name="id" value="{{.ID}}">
<div class="image block">
<!-- GIF video? -->
{{if HasSuffix .Filename ".mp4"}}
<video autoplay loop controls controlsList="nodownload" playsinline>
<source src="{{PhotoURL .Filename}}" type="video/mp4">
</video>
{{else}}
<img src="{{PhotoURL .Filename}}">
{{end}}
</div>
</div>
{{end}}
</div>
<div class="block">
Are you sure you want to
{{if eq .Intent "delete"}}
<strong class="has-text-danger">delete</strong>
{{else if eq .Intent "visibility"}}
<strong>update the visibility</strong> of
{{else}}
update
{{end}}
{{if ge (len .Photos) 2 -}}
these <strong>{{len .Photos}} photos?</strong>
{{- else -}}
this photo?
{{- end}}
</div>
<div class="block has-text-center">
{{if eq .Intent "delete"}}
<button type="submit" class="button is-danger">Delete Photo{{Pluralize (len .Photos)}}</button>
{{else}}
<button type="submit" class="button is-primary">Update Photo{{Pluralize (len .Photos)}}</button>
{{end}}
<button type="button" class="button" onclick="history.back()">Cancel</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}

View File

@ -73,6 +73,11 @@
<!-- Reusable card footer -->
{{define "card-footer"}}
<label class="card-footer-item checkbox">
<input type="checkbox" class="nonshy-edit-photo-id"
name="id"
value="{{.ID}}">
</label>
<a class="card-footer-item" href="/photo/edit?id={{.ID}}">
<span class="icon"><i class="fa fa-edit"></i></span>
<span>Edit</span>
@ -490,6 +495,9 @@
{{SimplePager .Pager}}
<!-- Form to wrap the gallery, e.g. for batch edits on user views. -->
<form action="/photo/batch-edit">
<!-- "Full" view style? (blog style) -->
{{if eq .ViewStyle "full"}}
{{range .Photos}}
@ -753,22 +761,117 @@
{{SimplePager .Pager}}
<!-- Admin change log link -->
{{if .CurrentUser.HasAdminScope "admin.changelog"}}
<div class="block">
<a href="/admin/changelog?table_name=photos{{if .User}}&about_user_id={{.User.ID}}{{end}}" class="button is-small has-text-warning">
<span class="icon"><i class="fa fa-peace mr-1"></i></span>
<span>{{if .User}}User{{else}}Site{{end}} Gallery change log</span>
</a>
<!-- Bulk user actions to their photos -->
{{if or .IsOwnPhotos (.CurrentUser.HasAdminScope "social.moderator.photo")}}
<hr>
<div class="columns is-multiline is-mobile my-4">
<div class="column is-narrow">
<div class="buttons has-addons">
<button type="button" class="button" id="nonshy-select-all">
<i class="fa fa-square-check"></i>
</button>
<button type="button" class="button" id="nonshy-select-none">
<i class="fa fa-square"></i>
</button>
</div>
</div>
<div class="column" id="nonshy-edit-buttons">
<button type="submit" class="button is-small is-danger is-outlined"
name="intent"
value="delete">
<i class="fa fa-trash mr-2"></i>
Delete
</button>
<button type="submit" class="button mx-1 is-small is-info is-outlined"
name="intent"
value="visibility">
<i class="fa fa-eye mr-2"></i>
Edit Visibility
</button>
<span id="nonshy-count-selected" class="is-size-7 ml-2"></span>
</div>
</div>
{{end}}
</form><!-- end gallery form for batch edits -->
</div>
</div>
{{end}}
<!-- Admin change log link -->
{{if .CurrentUser.HasAdminScope "admin.changelog"}}
<div class="block">
<a href="/admin/changelog?table_name=photos{{if .User}}&about_user_id={{.User.ID}}{{end}}" class="button is-small has-text-warning">
<span class="icon"><i class="fa fa-peace mr-1"></i></span>
<span>{{if .User}}User{{else}}Site{{end}} Gallery change log</span>
</a>
</div>
{{end}}
</div>
</div>
<script type="text/javascript">
{{if or .IsOwnPhotos (.CurrentUser.HasAdminScope "social.moderator.photo")}}
// Batch edit controls
document.addEventListener("DOMContentLoaded", () => {
const checkboxes = document.getElementsByClassName("nonshy-edit-photo-id"),
$checkAll = document.querySelector("#nonshy-select-all"),
$checkNone = document.querySelector("#nonshy-select-none"),
$countSelected = document.querySelector("#nonshy-count-selected"),
$submitButtons = document.querySelector("#nonshy-edit-buttons");
$submitButtons.style.display = "none";
const setAllChecked = (v) => {
for (let box of checkboxes) {
box.checked = v;
}
};
const areAnyChecked = () => {
let any = false,
count = 0;
for (let box of checkboxes) {
if (box.checked) {
any = true;
count++;
}
}
// update the selected count
$countSelected.innerHTML = count > 0 ? `${count} selected.` : "";
$countSelected.style.display = count > 0 ? "" : "none";
return any;
};
const showHideButtons = () => {
$submitButtons.style.display = areAnyChecked() ? "" : "none";
};
showHideButtons();
// Check/Uncheck All buttons.
$checkAll.addEventListener("click", (e) => {
setAllChecked(true);
showHideButtons();
});
$checkNone.addEventListener("click", (e) => {
setAllChecked(false);
showHideButtons();
});
// When checkboxes are toggled.
for (let box of checkboxes) {
box.addEventListener("change", (e) => {
showHideButtons();
});
}
});
{{end}}
document.addEventListener("DOMContentLoaded", () => {
// Get our modal to trigger it on click of a detail img.
let $modal = document.querySelector("#detail-modal"),