website/pkg/controller/photo/upload.go
Noah Petherbridge 666d3105b7 Privacy Improvements and Notification Fixes
* On user profile pages and gallery: the total photo count for the user
  will only include photos that the viewer can actually see (taking into
  account friendship and private grants), so that users won't harass
  each other to see the additional photos that aren't visible to them.
* On the member directory search: the photo counts will only show public
  photos on their page for now, and may be fewer than the number of
  photos the current user could actually see.
* Blocklist: you can now manually add a user by username to your block
  list. So if somebody blocked you on the site and you want to block
  them back, there is a way to do this.
* Friends: you can now directly unfriend someone from their profile
  page by clicking on the "Friends" button. You get a confirmation
  popup before the remove friend action goes through.
* Bugfix: when viewing a user's gallery, you were able to see their
  Friends-only photos if they granted you their Private photo access,
  even if you were not their friend.
* Bugfix: when uploading a new private photo, instead of notifying
  everybody you granted access to your privates it will only notify
  if they are also on your friend list.
2023-08-14 18:50:34 -07:00

224 lines
7.2 KiB
Go

package photo
import (
"bytes"
"io"
"net/http"
"os"
"path/filepath"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/photo"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// Upload photos controller.
func Upload() http.HandlerFunc {
tmpl := templates.Must("photo/upload.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var vars = map[string]interface{}{
"Intent": r.FormValue("intent"),
"NeedsCrop": false,
}
// Query string parameters: what is the intent of this photo upload?
// - If profile picture, the user will crop their image before posting it.
// - If regular photo, user simply picks a picture and doesn't need to crop it.
if vars["Intent"] == "profile_pic" {
vars["NeedsCrop"] = true
}
user, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
}
// Get the current user's quota.
var photoCount, photoQuota = photo.QuotaForUser(user)
vars["PhotoCount"] = photoCount
vars["PhotoQuota"] = photoQuota
// If they do not have a profile picture currently set (and are not uploading one now),
// the front-end should point this out to them.
if (user.ProfilePhotoID == nil || *user.ProfilePhotoID == 0) && vars["Intent"] != "profile_pic" {
// If they have no photo at all, make the default intent to upload one.
if photoCount == 0 {
templates.Redirect(w, r.URL.Path+"?intent=profile_pic")
return
}
vars["NoProfilePicture"] = true
}
// Are they POSTing?
if r.Method == http.MethodPost {
var (
caption = r.PostFormValue("caption")
isExplicit = r.PostFormValue("explicit") == "true"
visibility = r.PostFormValue("visibility")
isGallery = r.PostFormValue("gallery") == "true"
cropCoords = r.PostFormValue("crop")
confirm1 = r.PostFormValue("confirm1") == "true"
confirm2 = r.PostFormValue("confirm2") == "true"
)
// Are they at quota already?
if photoCount >= photoQuota {
session.FlashError(w, r, "You have too many photos to upload a new one. Please delete a photo to make room for a new one.")
templates.Redirect(w, "/photo/u/"+user.Username)
return
}
// They checked both boxes. The browser shouldn't allow them to
// post but validate it here anyway...
if !confirm1 || !confirm2 {
session.FlashError(w, r, "You must agree to the terms to upload this picture.")
templates.Redirect(w, r.URL.Path)
return
}
// Parse and validate crop coordinates.
var crop []int
if vars["NeedsCrop"] == true {
crop = photo.ParseCropCoords(cropCoords)
}
log.Error("parsed crop coords: %+v", crop)
// Get their file upload.
file, header, err := r.FormFile("file")
if err != nil {
session.FlashError(w, r, "Error receiving your file: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
// GIF can not be uploaded for a profile picture.
if filepath.Ext(header.Filename) == ".gif" && vars["Intent"] == "profile_pic" {
session.FlashError(w, r, "GIF images are not acceptable for your profile picture.")
templates.Redirect(w, r.URL.Path)
return
}
// Read the file contents.
log.Debug("Receiving uploaded file (%d bytes): %s", header.Size, header.Filename)
var buf bytes.Buffer
io.Copy(&buf, file)
filename, cropFilename, err := photo.UploadPhoto(photo.UploadConfig{
User: user,
Extension: filepath.Ext(header.Filename),
Data: buf.Bytes(),
Crop: crop,
})
if err != nil {
session.FlashError(w, r, "Error in UploadPhoto: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
// Configuration for the DB entry.
ptmpl := models.Photo{
UserID: user.ID,
Filename: filename,
CroppedFilename: cropFilename,
Caption: caption,
Visibility: models.PhotoVisibility(visibility),
Gallery: isGallery,
Explicit: isExplicit,
}
// Get the filesize.
if stat, err := os.Stat(photo.DiskPath(filename)); err == nil {
ptmpl.Filesize = stat.Size()
}
// Create it in DB!
p, err := models.CreatePhoto(ptmpl)
if err != nil {
session.FlashError(w, r, "Couldn't create Photo in DB: %s", err)
} else {
log.Info("New photo! %+v", p)
}
// Are we uploading a profile pic? If so, set the user's pic now.
if vars["Intent"] == "profile_pic" && cropFilename != "" {
log.Info("User %s is setting their profile picture", user.Username)
user.ProfilePhoto = *p
user.Save()
}
// Notify all of our friends that we posted a new picture.
go notifyFriendsNewPhoto(p, user)
session.Flash(w, r, "Your photo has been uploaded successfully.")
templates.Redirect(w, "/photo/u/"+user.Username)
return
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
// Create a notification for all our Friends about a new photo.
// Run in a background goroutine in case it takes a while.
func notifyFriendsNewPhoto(photo *models.Photo, currentUser *models.User) {
var (
friendIDs []uint64
notifyUserIDs []uint64
)
// Get the user's literal list of friends (with explicit opt-ins).
if photo.Explicit {
friendIDs = models.FriendIDsAreExplicit(currentUser.ID)
} else {
friendIDs = models.FriendIDs(currentUser.ID)
}
// Who to notify about this upload?
if photo.Visibility == models.PhotoPrivate {
// Private grantees
if photo.Explicit {
notifyUserIDs = models.PrivateGranteeAreExplicitUserIDs(currentUser.ID)
log.Info("Notify %d EXPLICIT private grantees about the new photo by %s", len(notifyUserIDs), currentUser.Username)
} else {
notifyUserIDs = models.PrivateGranteeUserIDs(currentUser.ID)
log.Info("Notify %d private grantees about the new photo by %s", len(notifyUserIDs), currentUser.Username)
}
} else if photo.Visibility == models.PhotoInnerCircle {
// Inner circle members. If the pic is also Explicit, further narrow to explicit friend IDs.
if photo.Explicit {
notifyUserIDs = models.FriendIDsInCircleAreExplicit(currentUser.ID)
log.Info("Notify %d EXPLICIT circle friends about the new photo by %s", len(notifyUserIDs), currentUser.Username)
} else {
notifyUserIDs = models.FriendIDsInCircle(currentUser.ID)
log.Info("Notify %d circle friends about the new photo by %s", len(notifyUserIDs), currentUser.Username)
}
} else {
// Friends only: we will notify exactly the friends we selected above.
notifyUserIDs = friendIDs
}
// Filter down the notifyUserIDs to only include the user's friends.
// Example: someone unlocked private photos for you, but you are not their friend.
// You should not get notified about their new private photos.
notifyUserIDs = models.FilterFriendIDs(notifyUserIDs, friendIDs)
for _, fid := range notifyUserIDs {
notif := &models.Notification{
UserID: fid,
AboutUser: *currentUser,
Type: models.NotificationNewPhoto,
TableName: "photos",
TableID: photo.ID,
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't notify user %d about %s's new photo: %s", fid, currentUser.Username, err)
}
}
}