Improvements on community flagged explicit photos

When a user marks that another photo should have been marked as explicit:

* The owner of that photo gets a notification about it, which reminds them of
  the explicit photo policy.
* The photo's "Flagged" boolean is set (along with the Explicit boolean)
* The 'Edit' page on a Flagged photo shows a red banner above the Explicit
  option, explaining that it was flagged. The checkbox text is crossed-out,
  with a "no" cursor and title text over - but can still be unchecked.

If the user removes the Explicit flag on a flagged photo and saves it:

* An admin report is generated to notify to take a look too.
* The Explicit flag is cleared as normal
* The Flagged boolean is also cleared on this photo: if they set it back to
  Explicit again themselves, the red banner won't appear and it won't notify
  again - unless a community member flagged it again!

Also makes some improvements to the admin page:

* On photo reports: show a blurred-out (clickable to reveal) photo on feedback
  items about photos.
This commit is contained in:
Noah Petherbridge 2024-10-01 20:44:11 -07:00
parent c8d9cdbb3a
commit 542d0bb300
13 changed files with 224 additions and 24 deletions

View File

@ -183,17 +183,30 @@ func Feedback() http.HandlerFunc {
} }
// Map user IDs. // Map user IDs.
var userIDs = []uint64{} var (
userIDs = []uint64{}
photoIDs = []uint64{}
)
for _, p := range page { for _, p := range page {
if p.UserID > 0 { if p.UserID > 0 {
userIDs = append(userIDs, p.UserID) userIDs = append(userIDs, p.UserID)
} }
if p.TableName == "photos" && p.TableID > 0 {
photoIDs = append(photoIDs, p.TableID)
}
} }
userMap, err := models.MapUsers(currentUser, userIDs) userMap, err := models.MapUsers(currentUser, userIDs)
if err != nil { if err != nil {
session.FlashError(w, r, "Couldn't map user IDs: %s", err) session.FlashError(w, r, "Couldn't map user IDs: %s", err)
} }
// Map photo IDs.
photoMap, err := models.MapPhotos(photoIDs)
if err != nil {
session.FlashError(w, r, "Couldn't map photo IDs: %s", err)
}
var vars = map[string]interface{}{ var vars = map[string]interface{}{
// Filter settings. // Filter settings.
"DistinctSubjects": models.DistinctFeedbackSubjects(), "DistinctSubjects": models.DistinctFeedbackSubjects(),
@ -205,6 +218,7 @@ func Feedback() http.HandlerFunc {
"Acknowledged": acknowledged, "Acknowledged": acknowledged,
"Feedback": page, "Feedback": page,
"UserMap": userMap, "UserMap": userMap,
"PhotoMap": photoMap,
"Pager": pager, "Pager": pager,
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {

View File

@ -52,6 +52,7 @@ func MarkPhotoExplicit() http.HandlerFunc {
} }
photo.Explicit = true photo.Explicit = true
photo.Flagged = true
if err := photo.Save(); err != nil { if err := photo.Save(); err != nil {
session.FlashError(w, r, "Couldn't save photo: %s", err) session.FlashError(w, r, "Couldn't save photo: %s", err)
} else { } else {

View File

@ -83,6 +83,7 @@ func MarkPhotoExplicit() http.HandlerFunc {
} }
photo.Explicit = true photo.Explicit = true
photo.Flagged = true
if err := photo.Save(); err != nil { if err := photo.Save(); err != nil {
SendJSON(w, http.StatusBadRequest, Response{ SendJSON(w, http.StatusBadRequest, Response{
Error: fmt.Sprintf("Couldn't save the photo: %s", err), Error: fmt.Sprintf("Couldn't save the photo: %s", err),
@ -90,6 +91,23 @@ func MarkPhotoExplicit() http.HandlerFunc {
return return
} }
// Send the photo's owner a notification so they are aware.
if owner, err := models.GetUser(photo.UserID); err == nil {
notif := &models.Notification{
UserID: owner.ID,
AboutUser: *owner,
Type: models.NotificationExplicitPhoto,
TableName: "photos",
TableID: photo.ID,
Link: fmt.Sprintf("/photo/view?id=%d", photo.ID),
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create Likes notification: %s", err)
}
} else {
log.Error("MarkExplicit: getting photo owner (%d): %s", photo.UserID, err)
}
// If a non-admin user has hit this API, log an admin report for visibility and // If a non-admin user has hit this API, log an admin report for visibility and
// to keep a pulse on things (e.g. in case of abuse). // to keep a pulse on things (e.g. in case of abuse).
if !currentUser.IsAdmin { if !currentUser.IsAdmin {

View File

@ -88,6 +88,9 @@ func Edit() http.HandlerFunc {
// Are we GOING private? // Are we GOING private?
goingPrivate = visibility == models.PhotoPrivate && visibility != photo.Visibility goingPrivate = visibility == models.PhotoPrivate && visibility != photo.Visibility
// Is the user fighting an 'Explicit' tag added by the community?
isFightingExplicitFlag = photo.Flagged && photo.Explicit && !isExplicit
) )
if len(altText) > config.AltTextMaxLength { if len(altText) > config.AltTextMaxLength {
@ -141,6 +144,31 @@ func Edit() http.HandlerFunc {
setProfilePic = false setProfilePic = false
} }
// If the user is fighting a recent Explicit flag from the community.
if isFightingExplicitFlag {
// Notify the admin (unless we are an admin).
if !requestUser.IsAdmin {
fb := &models.Feedback{
Intent: "report",
Subject: "Explicit photo flag dispute",
UserID: currentUser.ID,
TableName: "photos",
TableID: photo.ID,
Message: "A user's photo was recently **flagged by the community** as Explicit, and its owner " +
"has **removed** the Explicit setting.\n\n" +
"Please check out the photo below and verify what its Explicit setting should be:",
}
if err := models.CreateFeedback(fb); err != nil {
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
}
}
// Allow this change but clear the Flagged status.
photo.Flagged = false
}
if err := photo.Save(); err != nil { if err := photo.Save(); err != nil {
session.FlashError(w, r, "Couldn't save photo: %s", err) session.FlashError(w, r, "Couldn't save photo: %s", err)
} }

View File

@ -46,6 +46,7 @@ const (
NotificationPrivatePhoto NotificationType = "private_photo" // private photo grants NotificationPrivatePhoto NotificationType = "private_photo" // private photo grants
NotificationNewPhoto NotificationType = "new_photo" NotificationNewPhoto NotificationType = "new_photo"
NotificationForumModerator NotificationType = "forum_moderator" // appointed as a forum moderator NotificationForumModerator NotificationType = "forum_moderator" // appointed as a forum moderator
NotificationExplicitPhoto NotificationType = "explicit_photo" // user photo was flagged explicit
NotificationCustom NotificationType = "custom" // custom message pushed NotificationCustom NotificationType = "custom" // custom message pushed
) )

View File

@ -85,7 +85,13 @@ func (nf NotificationFilter) Query() (where string, placeholders []interface{},
types = append(types, NotificationPrivatePhoto) types = append(types, NotificationPrivatePhoto)
} }
if nf.Misc { if nf.Misc {
types = append(types, NotificationFriendApproved, NotificationCertApproved, NotificationCertRejected, NotificationCustom) types = append(types,
NotificationFriendApproved,
NotificationCertApproved,
NotificationCertRejected,
NotificationExplicitPhoto,
NotificationCustom,
)
} }
return "type IN ?", types, true return "type IN ?", types, true

View File

@ -274,6 +274,54 @@ func GetOrphanedPhotos() ([]*Photo, int64, error) {
return ps, count, res.Error return ps, count, res.Error
} }
// PhotoMap helps map a set of users to look up by ID.
type PhotoMap map[uint64]*Photo
// MapPhotos looks up a set of photos IDs in bulk and returns a PhotoMap suitable for templates.
func MapPhotos(photoIDs []uint64) (PhotoMap, error) {
var (
photoMap = PhotoMap{}
set = map[uint64]interface{}{}
distinct = []uint64{}
)
// Uniqueify the IDs.
for _, uid := range photoIDs {
if _, ok := set[uid]; ok {
continue
}
set[uid] = nil
distinct = append(distinct, uid)
}
var (
photos = []*Photo{}
result = DB.Model(&Photo{}).Where("id IN ?", distinct).Find(&photos)
)
if result.Error == nil {
for _, row := range photos {
photoMap[row.ID] = row
}
}
return photoMap, result.Error
}
// Has a photo ID in the map?
func (pm PhotoMap) Has(id uint64) bool {
_, ok := pm[id]
return ok
}
// Get a photo from the PhotoMap.
func (pm PhotoMap) Get(id uint64) *Photo {
if photo, ok := pm[id]; ok {
return photo
}
return nil
}
/* /*
IsSiteGalleryThrottled returns whether the user is throttled from marking additional pictures for the Site Gallery. IsSiteGalleryThrottled returns whether the user is throttled from marking additional pictures for the Site Gallery.

View File

@ -16,6 +16,10 @@ abbr {
cursor: default; cursor: default;
} }
.has-text-smaller {
font-size: smaller;
}
img { img {
/* https://stackoverflow.com/questions/12906789/preventing-an-image-from-being-draggable-or-selectable-without-using-js */ /* https://stackoverflow.com/questions/12906789/preventing-an-image-from-being-draggable-or-selectable-without-using-js */
user-drag: none; user-drag: none;

View File

@ -600,6 +600,11 @@
You have been appointed as a <strong class="has-text-success">moderator</strong> You have been appointed as a <strong class="has-text-success">moderator</strong>
for the forum <a href="/f/{{$Body.Forum.Fragment}}">{{$Body.Forum.Title}}</a>! for the forum <a href="/f/{{$Body.Forum.Fragment}}">{{$Body.Forum.Title}}</a>!
</span> </span>
{{else if eq .Type "explicit_photo"}}
<span class="icon"><i class="fa fa-fire has-text-danger"></i></span>
<span>
Your <a href="{{.Link}}">photo</a> was marked as <span class="has-text-danger">Explicit!</span>
</span>
{{else}} {{else}}
{{.AboutUser.Username}} {{.Type}} {{.TableName}} {{.TableID}} {{.AboutUser.Username}} {{.Type}} {{.TableName}} {{.TableID}}
{{end}} {{end}}
@ -628,6 +633,18 @@
<span class="icon"><i class="fa fa-arrow-right"></i></span> <span class="icon"><i class="fa fa-arrow-right"></i></span>
<a href="{{.Link}}">See all comments</a> <a href="{{.Link}}">See all comments</a>
</div> </div>
{{else if eq .Type "explicit_photo"}}
<div class="content pt-1" style="font-size: smaller">
<p>
A community member thinks that this photo should have been marked as 'Explicit' when
it was uploaded.
</p>
<p>
Please <a href="/tos#explicit-photos">review our Explicit Photos policy</a>
and remember to correctly mark your new uploads as 'explicit' when they contain sexually
suggestive content.
</p>
</div>
{{else}} {{else}}
<em>{{or $Body.Photo.Caption "No caption."}}</em> <em>{{or $Body.Photo.Caption "No caption."}}</em>
{{end}} {{end}}

View File

@ -223,11 +223,24 @@
</table> </table>
<div class="content"> <div class="content">
{{if eq .Message ""}} {{if eq .Message ""}}
<p><em>No message attached.</em></p> <p><em>No message attached.</em></p>
{{else}} {{else}}
{{ToMarkdown .Message}} {{ToMarkdown .Message}}
{{end}} {{end}}
<!-- Photo attachment? -->
{{if eq .TableName "photos"}}
{{$Photo := $Root.PhotoMap.Get .TableID}}
{{if $Photo}}
<div class="is-clipped">
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true">
<img src="{{PhotoURL $Photo.Filename}}"
class="blurred-explicit">
</a>
</div>
{{end}}
{{end}}
</div> </div>
</div> </div>

View File

@ -701,6 +701,12 @@
content from other users -- by default this site is "normal nudes" friendly! content from other users -- by default this site is "normal nudes" friendly!
</p> </p>
<p>
Please see the <a href="/tos#explicit-photos">Explicit Photos &amp; Sexual Content</a>
policy on our <a href="/tos">Terms of Service</a> for some examples when a photo should
be marked as 'explicit.'
</p>
<h3 id="photoshop">Are digitally altered or 'photoshopped' pictures okay?</h3> <h3 id="photoshop">Are digitally altered or 'photoshopped' pictures okay?</h3>
<p> <p>

View File

@ -264,7 +264,6 @@
<label class="label"> <label class="label">
<i class="fa fa-thumbtack mr-1 has-text-success"></i> <i class="fa fa-thumbtack mr-1 has-text-success"></i>
Pinned Photo Pinned Photo
<span class="tag is-success ml-2">New!</span>
</label> </label>
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" <input type="checkbox"
@ -391,6 +390,34 @@
<span>Explicit Content</span> <span>Explicit Content</span>
<span class="icon"><i class="fa fa-fire"></i></span> <span class="icon"><i class="fa fa-fire"></i></span>
</label> </label>
<!-- Flagged Explicit photo: show a warning if this photo was recently flagged by the community. -->
{{$IsFlagged := and .EditPhoto .EditPhoto.Flagged .EditPhoto.Explicit}}
{{if $IsFlagged}}
<div class="notification is-danger is-light py-3 px-3 has-text-smaller content">
<p>
<strong>
<i class="fa fa-exclamation-triangle"></i>
Notice:
</strong>
This photo was classified by the community as containing 'Explicit' content.
</p>
<p>
Please review <a href="/tos#explicit-photos">what {{.Title}} considers an 'Explicit' photo</a>
and if this photo fits the description, <strong>please</strong> leave this photo with the
'Explicit' box checked, below.
</p>
<p>
If you disagree that this photo should have been marked as 'Explicit,' you <strong>MAY</strong>
uncheck the box below and remove the Explicit status. <strong>Note:</strong> the website admin will
be notified to take a look as well if you do this, to verify that your photo has the correct 'Explicit'
setting.
</p>
</div>
{{end}}
{{if eq .Intent "profile_pic"}} {{if eq .Intent "profile_pic"}}
<span class="has-text-danger"> <span class="has-text-danger">
Your default profile picture should Your default profile picture should
@ -405,19 +432,23 @@
that to your page, just not as your default profile picture! that to your page, just not as your default profile picture!
</p> </p>
{{else}} {{else}}
<label class="checkbox"> <label class="checkbox"
<input type="checkbox" {{if $IsFlagged}}title="You MAY remove this check if you disagree that this photo should be marked Explicit"{{end}}
name="explicit" >
value="true" <input type="checkbox"
{{if .EditPhoto.Explicit}}checked{{end}}> name="explicit"
This photo contains explicit content value="true"
</label> {{if .EditPhoto.Explicit}}checked{{end}}>
<p class="help"> {{if $IsFlagged}}<del class="cursor-not-allowed">{{end}}
Mark this box if this photo contains any explicit content, including an This photo contains explicit content
erect penis, close-up of genitalia, or any depiction of sexual activity. {{if $IsFlagged}}</del>{{end}}
Use your best judgment. "Normal nudes" such as full body nudes in a </label>
non-sexual context do not need to check this box. <p class="help">
</p> Mark this box if this photo contains any explicit content, including an
erect penis, close-up of genitalia, or any depiction of sexual activity.
Use your best judgment. "Normal nudes" such as full body nudes in a
non-sexual context do not need to check this box.
</p>
{{end}} {{end}}
</div> </div>

View File

@ -380,20 +380,20 @@
</p> </p>
<p> <p>
A photo is considered "explicit" if it depicts <em>any</em> of the following features: A photo is considered "explicit" if it depicts <strong>any</strong> of the following features:
</p> </p>
<ul> <ul>
<li> <li>
A close-up view of genitalia or where the genitals are the central focus of the picture. A close-up view of genitalia or where the genitals are the central focus of the picture.
</li> </li>
<li>An erect penis if the subject has one, especially if they are grabbing it.</li> <li>An erect or semi-erect penis if the subject has one, especially if they are grabbing it.</li>
<li> <li>
"Spread eagle" pictures that clearly and especially show intimate body parts such "Spread eagle" pictures that clearly and especially show intimate body parts such
as butt holes or vulvae. as butt holes or vulvae.
</li> </li>
<li> <li>
A depiction of a sexual act, including but not limited to: masturbation, oral sex, A depiction of any sexual activity, including but not limited to: masturbation, oral sex,
anal or vaginal penetration, humping, or any content intended to sexually arouse the anal or vaginal penetration, humping, or any content intended to sexually arouse the
viewer. If it can be reasonably considered to be "porn" it is an explicit photo. viewer. If it can be reasonably considered to be "porn" it is an explicit photo.
</li> </li>
@ -405,6 +405,17 @@
</li> </li>
</ul> </ul>
<p>
As a general rule of thumb: if a picture could be reasonably considered to be "porn" then you should
mark it as Explicit when uploading it to your gallery.
</p>
<p>
<strong>Important:</strong> extreme and commonly offensive content (such as fisting, gaping or prolapsed
ass holes, etc.) are <strong>NOT</strong> permitted on {{PrettyTitle}}. Please review the following
section for a list of Prohibited Content.
</p>
<h3 id="prohibited-content">Prohibited Content <a href="#prohibited-content" class="fa fa-paragraph is-size-6"></a></h3> <h3 id="prohibited-content">Prohibited Content <a href="#prohibited-content" class="fa fa-paragraph is-size-6"></a></h3>
<p> <p>
@ -414,6 +425,7 @@
<ul> <ul>
<li> <li>
<strong>Illegal content:</strong>
You may NOT upload any content that is considered to be illegal in the United States or You may NOT upload any content that is considered to be illegal in the United States or
in any of the 50 States therein. This includes, but is not limited to: bestiality (or in any of the 50 States therein. This includes, but is not limited to: bestiality (or
sexual acts involving animals), child sexually abusive material (CSAM), ANY nude photo sexual acts involving animals), child sexually abusive material (CSAM), ANY nude photo
@ -423,6 +435,7 @@
or other unlawful content. or other unlawful content.
</li> </li>
<li> <li>
<strong>Extreme content:</strong>
You may NOT upload sexual material depicting extreme or commonly offensive content You may NOT upload sexual material depicting extreme or commonly offensive content
including, but not limited to: watersports (peeing onto or into another person), scat including, but not limited to: watersports (peeing onto or into another person), scat
(any depiction of obviously apparent fecal matter), prolapsed rectum, anal fisting, (any depiction of obviously apparent fecal matter), prolapsed rectum, anal fisting,