From 4f04323d5a2a8afae35aa3ae4d0376b31f7ce12a Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 29 Jun 2024 16:44:18 -0700 Subject: [PATCH] Public Avatar Consent Page The nonshy website is changing the policy on profile pictures. From August 30, the square cropped avatar images will need to be publicly viewable to everyone. This implements the first pass of the rollout: * Add the Public Avatar Consent Page which explains the change to users and asks for their acknowledgement. The link is available from their User Settings page, near their Certification Photo link. * When users (with non-public avatars) accept the change: their square cropped avatar will become visible to everybody, instead of showing a placeholder avatar. * Users can change their mind and opt back out, which will again show the placeholder avatar. * The Certification Required middleware will automatically enforce the consent page once the scheduled go-live date arrives. Next steps are: 1. Post an announcement on the forum about the upcoming change and link users to the consent form if they want to check it out early. 2. Update the nonshy site to add banners to places like the User Dashboard for users who will be affected by the change, to link them to the forum post and the consent page. --- pkg/config/config.go | 9 + pkg/config/enum.go | 3 +- .../account/public_avatar_consent.go | 55 ++++ pkg/controller/chat/chat.go | 16 +- pkg/middleware/authentication.go | 5 + pkg/middleware/public_avatar_consent.go | 31 ++ pkg/models/shy_accounts.go | 37 ++- pkg/models/user.go | 6 +- pkg/models/user_relationship.go | 21 +- pkg/router/router.go | 1 + pkg/templates/template_vars.go | 3 + .../account/public_avatar_consent.html | 296 ++++++++++++++++++ web/templates/account/settings.html | 9 + web/templates/partials/user_avatar.html | 30 +- 14 files changed, 493 insertions(+), 29 deletions(-) create mode 100644 pkg/controller/account/public_avatar_consent.go create mode 100644 pkg/middleware/public_avatar_consent.go create mode 100644 web/templates/account/public_avatar_consent.html diff --git a/pkg/config/config.go b/pkg/config/config.go index 7bf1235..5ce6b04 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -118,6 +118,15 @@ const ( SiteGalleryRateLimitInterval = 24 * time.Hour ) +// Profile photo "public square cropped version" migration controls. +// Previously: a Private or Friends-only profile picture would show a placeholder avatar to users +// who couldn't see the full size version. In migration, these square cropped images will move to +// be always public (the full-size versions can remain private). +// NOTE: search code base for PublicAvatar or "public avatar" for related feature code. +var ( + PublicAvatarEnforcementDate = time.Date(2024, time.August, 30, 12, 0, 0, 0, time.UTC) +) + // Forum settings const ( // Only ++ the Views count per user per thread within a small diff --git a/pkg/config/enum.go b/pkg/config/enum.go index 2fa7c4b..ac447c2 100644 --- a/pkg/config/enum.go +++ b/pkg/config/enum.go @@ -78,7 +78,8 @@ var ( SitePreferenceFields = []string{ "dm_privacy", "blur_explicit", - "site_gallery_default", // default view on site gallery (friends-only or all certified?) + "site_gallery_default", // default view on site gallery (friends-only or all certified?) + "public_avatar_consent", // Public Avatars consent agreement } // Choices for the Contact Us subject diff --git a/pkg/controller/account/public_avatar_consent.go b/pkg/controller/account/public_avatar_consent.go new file mode 100644 index 0000000..7bcc7f6 --- /dev/null +++ b/pkg/controller/account/public_avatar_consent.go @@ -0,0 +1,55 @@ +package account + +import ( + "net/http" + "strings" + + "code.nonshy.com/nonshy/website/pkg/session" + "code.nonshy.com/nonshy/website/pkg/templates" +) + +// Public avatar consent page (/settings/public-avatar-consent) +func PublicAvatarConsent() http.HandlerFunc { + tmpl := templates.Must("account/public_avatar_consent.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var next = r.FormValue("next") + if !strings.HasPrefix(next, "/") { + next = "/me" + } + vars := map[string]interface{}{ + "NextURL": next, + } + + // Load the current user in case of updates. + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get CurrentUser: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + // Are we POSTing? + if r.Method == http.MethodPost { + var ( + accept = r.PostFormValue("accept") + ) + + currentUser.SetProfileField("public_avatar_consent", accept) + + if accept == "true" { + session.Flash(w, r, "Thank you for agreeing to the Public Avatar policy! You may revisit this option from your Account Settings page in the future.") + templates.Redirect(w, next) + return + } + + session.Flash(w, r, "We have noted your non-consent to this change. Your avatar image (if non-public) will show as a placeholder avatar to people who don't have permission to see the full-size photo.") + templates.Redirect(w, r.URL.Path) + return + } + + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/chat/chat.go b/pkg/controller/chat/chat.go index ca5f5c5..043f671 100644 --- a/pkg/controller/chat/chat.go +++ b/pkg/controller/chat/chat.go @@ -99,13 +99,15 @@ func Landing() http.HandlerFunc { // Avatar URL - masked if non-public. avatar := photo.URLPath(currentUser.ProfilePhoto.CroppedFilename) - switch currentUser.ProfilePhoto.Visibility { - case models.PhotoPrivate: - avatar = "/static/img/shy-private.png" - case models.PhotoFriends: - avatar = "/static/img/shy-friends.png" - case models.PhotoInnerCircle: - avatar = "/static/img/shy-secret.png" + if currentUser.GetProfileField("public_avatar_consent") != "true" { + switch currentUser.ProfilePhoto.Visibility { + case models.PhotoPrivate: + avatar = "/static/img/shy-private.png" + case models.PhotoFriends: + avatar = "/static/img/shy-friends.png" + case models.PhotoInnerCircle: + avatar = "/static/img/shy-secret.png" + } } // Country flag emoji. diff --git a/pkg/middleware/authentication.go b/pkg/middleware/authentication.go index 6c33f5f..c0c7421 100644 --- a/pkg/middleware/authentication.go +++ b/pkg/middleware/authentication.go @@ -157,6 +157,11 @@ func CertRequired(handler http.Handler) http.Handler { return } + // Public Avatar consent enforcement? + if PublicAvatarConsent(currentUser, w, r) { + return + } + handler.ServeHTTP(w, r) }) } diff --git a/pkg/middleware/public_avatar_consent.go b/pkg/middleware/public_avatar_consent.go new file mode 100644 index 0000000..e05ff77 --- /dev/null +++ b/pkg/middleware/public_avatar_consent.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "net/http" + "net/url" + "time" + + "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/models" + "code.nonshy.com/nonshy/website/pkg/templates" +) + +// PublicAvatarConsent: part of CertificationRequired that enforces the consent page for affected users. +func PublicAvatarConsent(user *models.User, w http.ResponseWriter, r *http.Request) (handled bool) { + // Is the enforcement date live? + if time.Now().Before(config.PublicAvatarEnforcementDate) { + return + } + + // If the current user has a non-public avatar and has not consented. + if user.ProfilePhoto.ID > 0 && user.ProfilePhoto.Visibility != models.PhotoPublic { + if user.GetProfileField("public_avatar_consent") == "true" { + return + } + + templates.Redirect(w, "/settings/public-avatar-consent?next="+url.QueryEscape(r.URL.String())) + return true + } + + return +} diff --git a/pkg/models/shy_accounts.go b/pkg/models/shy_accounts.go index 8777ed1..4d037c9 100644 --- a/pkg/models/shy_accounts.go +++ b/pkg/models/shy_accounts.go @@ -1,6 +1,6 @@ package models -// Supplementary functions to do with Shy Accounts. +// Supplementary functions to do with Shy Accounts and PublicAvatar consent. import "code.nonshy.com/nonshy/website/pkg/log" @@ -96,3 +96,38 @@ func MapShyAccounts(users []*User) ShyMap { func (um ShyMap) Get(id uint64) bool { return um[id] } + +// MapPublicAvatarConsent checks a set of users if they have answered the public avatar consent form. +func MapPublicAvatarConsent(users []*User) (map[uint64]bool, error) { + var ( + userIDs = []uint64{} + result = map[uint64]bool{} + ) + for _, user := range users { + userIDs = append(userIDs, user.ID) + result[user.ID] = false + } + + type record struct { + UserID uint64 + Value string + } + var rows = []record{} + + if res := DB.Table( + "profile_fields", + ).Select( + "user_id, value", + ).Where( + "user_id IN ? AND name='public_avatar_consent'", + userIDs, + ).Scan(&rows); res.Error != nil { + return nil, res.Error + } + + for _, row := range rows { + result[row.UserID] = row.Value == "true" + } + + return result, nil +} diff --git a/pkg/models/user.go b/pkg/models/user.go index 76022ac..6e12f5c 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -637,8 +637,12 @@ func (u *User) NameOrUsername() string { // // Expects that UserRelationships are available on the user. func (u *User) VisibleAvatarURL(currentUser *User) string { + // Can the viewer see the picture based on its visibility setting? canSee, visibility := u.CanSeeProfilePicture(currentUser) - if canSee { + + // Or has the owner consented on the Public Avatar policy? + consent := u.GetProfileField("public_avatar_consent") == "true" + if canSee || consent { return config.PhotoWebPath + "/" + u.ProfilePhoto.CroppedFilename } diff --git a/pkg/models/user_relationship.go b/pkg/models/user_relationship.go index ebaf141..ca67ee9 100644 --- a/pkg/models/user_relationship.go +++ b/pkg/models/user_relationship.go @@ -1,14 +1,17 @@ package models +import "code.nonshy.com/nonshy/website/pkg/log" + // UserRelationship fields - how a target User relates to the CurrentUser, especially // with regards to whether the User's friends-only or private profile picture should show. // The zero-values should fail safely: in case the UserRelationship isn't populated correctly, // private profile pics show as private by default. type UserRelationship struct { - Computed bool // check whether the SetUserRelationships function has been run - IsFriend bool // if true, a friends-only profile pic can show - IsPrivateGranted bool // if true, a private profile pic can show - IsInnerCirclePeer bool // if true, the current user is in the inner circle so may see circle-only profile picture + Computed bool // check whether the SetUserRelationships function has been run + IsFriend bool // if true, a friends-only profile pic can show + IsPrivateGranted bool // if true, a private profile pic can show + IsInnerCirclePeer bool // if true, the current user is in the inner circle so may see circle-only profile picture + PublicAvatarConsent bool // if true, this user answered the Public Avatar consent form } // SetUserRelationships updates a set of User objects to populate their UserRelationships in @@ -35,6 +38,12 @@ func SetUserRelationships(currentUser *User, users []*User) error { privateMap[id] = nil } + // Get user PublicAvatar consent maps. + consent, err := MapPublicAvatarConsent(users) + if err != nil { + log.Error("MapPublicAvatarConsent: %s", err) + } + // Inject the UserRelationships. for _, u := range users { u.UserRelationship.IsInnerCirclePeer = isInnerCircle @@ -53,6 +62,10 @@ func SetUserRelationships(currentUser *User, users []*User) error { if _, ok := privateMap[u.ID]; ok { u.UserRelationship.IsPrivateGranted = true } + + if v, ok := consent[u.ID]; ok { + u.UserRelationship.PublicAvatarConsent = v + } } return nil } diff --git a/pkg/router/router.go b/pkg/router/router.go index a5bb6ba..a2c778d 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -48,6 +48,7 @@ func New() http.Handler { mux.Handle("/me", middleware.LoginRequired(account.Dashboard())) mux.Handle("/settings", middleware.LoginRequired(account.Settings())) mux.Handle("/settings/age-gate", middleware.LoginRequired(account.AgeGate())) + mux.Handle("/settings/public-avatar-consent", middleware.LoginRequired(account.PublicAvatarConsent())) mux.Handle("/account/two-factor/setup", middleware.LoginRequired(account.Setup2FA())) mux.Handle("/account/delete", middleware.LoginRequired(account.Delete())) mux.Handle("/account/deactivate", middleware.LoginRequired(account.Deactivate())) diff --git a/pkg/templates/template_vars.go b/pkg/templates/template_vars.go index f73e8f8..339adc2 100644 --- a/pkg/templates/template_vars.go +++ b/pkg/templates/template_vars.go @@ -23,6 +23,9 @@ func MergeVars(r *http.Request, m map[string]interface{}) { // Integrations m["TurnstileCAPTCHA"] = config.Current.Turnstile + // Temporary? variables for migration efforts on PublicAvatar consent. + m["PublicAvatarEnforcementDate"] = config.PublicAvatarEnforcementDate + if r == nil { return } diff --git a/web/templates/account/public_avatar_consent.html b/web/templates/account/public_avatar_consent.html new file mode 100644 index 0000000..c23d9da --- /dev/null +++ b/web/templates/account/public_avatar_consent.html @@ -0,0 +1,296 @@ +{{define "title"}}An update about your Default Profile Picture{{end}} +{{define "content"}} +
+
+
+
+

+ An update about your Default Profile Picture +

+

+ Please review the upcoming website change about our Default Profile Picture policy +

+
+
+
+ +
+
+
+
+ +
+ +
+

+ This page serves as a notification about an upcoming change to the {{PrettyTitle}} site + policy around the visibility of your square cropped Default Profile Picture + (which we will refer to as your "Avatar" for the purpose of this page). +

+ +

+ Beginning on {{.PublicAvatarEnforcementDate.Format "January 2, 2006"}}, + all {{PrettyTitle}} members will be required to make their Avatar (the square cropped, + default profile picture which includes your face) visible to all members of the website. + After {{.PublicAvatarEnforcementDate.Format "January 2"}}, you will not be permitted to + use the chat room, forums, site gallery or other 'certification required' pages until you + have responded to this consent form by answering the prompt at the bottom of this page. +

+ + + +
+ +

Summary of the change

+ +
    +
  • + On the {{PrettyTitle}} website, all square cropped default profile pictures + will become visible to everybody on the site, instead of showing as a yellow or purple + placeholder image when your full-size profile picture was set to "Friends-only" or "Private" + visibility. +
  • +
  • + The full size profile photo on your gallery can remain as "Friends-only" or + "Private" if you want; but the square Avatar shown next to your name on most pages of the + website will be visible to all logged-in members. +
  • +
  • + The website policy that your Avatar must show your face will also be more + strictly enforced along with this change. We want to know that everybody on {{PrettyTitle}} + has a face that we can all see. +
  • +
  • + Your consent is required for this change. If your + profile picture is currently non-public, we will not begin showing it + to others on the website until you acknowledge and agree to this change in site policy. +
  • +
+ +
+ +

What is being changed about our Avatars?

+ +

+ Previously, it was possible to set your Default Profile Picture on your gallery page as + "Private" or "Friends-only" visibility, and it would cause your square cropped Avatar + icon to show as a placeholder image to people who were not allowed to see the full-size copy + on your gallery. +

+ +

+ For example, if your profile picture was set to "Friends-only" then a + yellow placeholder avatar + would be shown in its place to people who are not on your Friends list, or a + purple placeholder avatar + would stand in for your "Private" profile picture to people who you did not grant access. +

+ +

+ Going forward, the square cropped Avatar images will be moved to become + visible to everybody who is logged-in to the website. Here + is an example of how your profile card may look to people before, and after + this change: +

+
+ +
+
+ + Before: + + +
+
+
+ {{if eq .CurrentUser.ProfilePhoto.Visibility "friends"}} + + {{else if eq .CurrentUser.ProfilePhoto.Visibility "private"}} + + {{else if .CurrentUser.ProfilePhoto.ID}} + + {{else}} + + {{end}} +
+
+
+

+ {{.CurrentUser.NameOrUsername}} +

+

+ + {{.CurrentUser.Username}} +

+
+
+
+ +
+ + After: + + +
+
+
+ {{if .CurrentUser.ProfilePhoto.ID}} + + {{else}} + + {{end}} +
+
+
+

+ {{.CurrentUser.NameOrUsername}} +

+

+ + {{.CurrentUser.Username}} +

+
+
+
+
+ + + {{if and (.CurrentUser.ProfilePhoto.ID) (eq .CurrentUser.ProfilePhoto.Visibility "public")}} +
+ Notice: your default profile picture is already set to 'Public' visibility, + so this change in site policy will not affect you. +
+ {{end}} + +
+
+ +

Your full-size profile picture can still remain private

+ +

+ This change in website policy only applies to the + square cropped Avatar image of your profile picture -- the + image shown on the top of your profile page and next to your name on the chat + room, forums, and other places around the {{PrettyTitle}} website. +

+ +

+ Your full size default profile photo can still be kept on "Friends-only" + or "Private" visibility. So, for example: if your default profile photo is a full-body nude + picture that includes your face, you could crop your Avatar to show only your face (visible + to everybody) but you can leave the full-size photo on Private if you don't want everybody + to see it. +

+ +
+ + + +

+ If your profile picture is currently "Private" or "Friends-only," we will not + automatically reveal your Avatar to everybody without your consent. We know that for some of + you, the ability to hide your face picture from other members on the site is very important. +

+ +

+ While the new policy will go into effect on {{.PublicAvatarEnforcementDate.Format "January 2, 2006"}}, + you may accept the updated policy early by clicking on the checkbox below. + After {{.PublicAvatarEnforcementDate.Format "January 2"}}, if you have not yet + accepted the new policy, you will be shown this page any time you access the "Certification Required" + areas of the website (such as the chat room, forums, gallery and member directory). All members who + have a non-public profile picture will need to accept this change after that date to continue using + the website. +

+
+ +
+ + {{InputCSRF}} + +
+ + +

+ After accepting this change: if your default profile picture is currently non-public, the + square cropped avatar image will become visible for everybody. +

+
+ +
+ +
+ +
+ +
+
+ +

What if I disagree with this change?

+ +

+ After {{.PublicAvatarEnforcementDate.Format "January 2, 2006"}}, when this change + in website policy goes live, members who have a non-public default profile picture and have + not consented to the above update will no longer have access to the "Certification + Required" areas of the website. +

+ +

+ That is: the Chat Room, Forums, Site Gallery, and Member Directory will no longer be accessible to + you from that time. When visiting any of those areas of the website, you will be redirected to + this consent page which you will need to agree to before you can continue using the website. +

+ +

+ You will still have access to your Friends list, Messages, profile pages and your account settings + so that you can make arrangements with your friends to connect on a different platform. + If this change in site policy is simply incompatible with you, then we would ask that you + delete your {{PrettyTitle}} account. +

+
+
+
+
+
+
+ +
+ + +{{end}} diff --git a/web/templates/account/settings.html b/web/templates/account/settings.html index ba7d67c..a7c3cb6 100644 --- a/web/templates/account/settings.html +++ b/web/templates/account/settings.html @@ -113,6 +113,15 @@ +
  • + + Public Avatar Consent +

    + Review and acknowledge the change in default profile picture policy. +

    +
    +
  • +
  • Private Photos diff --git a/web/templates/partials/user_avatar.html b/web/templates/partials/user_avatar.html index 2d96b3d..9c40120 100644 --- a/web/templates/partials/user_avatar.html +++ b/web/templates/partials/user_avatar.html @@ -5,11 +5,11 @@
    {{if .ProfilePhoto.ID}} - {{if and (eq .ProfilePhoto.Visibility "private") (not .UserRelationship.IsPrivateGranted)}} + {{if and (eq .ProfilePhoto.Visibility "private") (not .UserRelationship.IsPrivateGranted) (not .UserRelationship.PublicAvatarConsent)}} - {{else if and (eq .ProfilePhoto.Visibility "friends") (not .UserRelationship.IsFriend)}} + {{else if and (eq .ProfilePhoto.Visibility "friends") (not .UserRelationship.IsFriend) (not .UserRelationship.PublicAvatarConsent)}} - {{else if and (eq .ProfilePhoto.Visibility "circle") (not .UserRelationship.IsInnerCirclePeer)}} + {{else if and (eq .ProfilePhoto.Visibility "circle") (not .UserRelationship.IsInnerCirclePeer) (not .UserRelationship.PublicAvatarConsent)}} {{else}} @@ -26,11 +26,11 @@
    {{if .ProfilePhoto.ID}} - {{if and (eq .ProfilePhoto.Visibility "private") (not .UserRelationship.IsPrivateGranted)}} + {{if and (eq .ProfilePhoto.Visibility "private") (not .UserRelationship.IsPrivateGranted) (not .UserRelationship.PublicAvatarConsent)}} - {{else if and (eq .ProfilePhoto.Visibility "friends") (not .UserRelationship.IsFriend)}} + {{else if and (eq .ProfilePhoto.Visibility "friends") (not .UserRelationship.IsFriend) (not .UserRelationship.PublicAvatarConsent)}} - {{else if and (eq .ProfilePhoto.Visibility "circle") (not .UserRelationship.IsInnerCirclePeer)}} + {{else if and (eq .ProfilePhoto.Visibility "circle") (not .UserRelationship.IsInnerCirclePeer) (not .UserRelationship.PublicAvatarConsent)}} {{else}} @@ -47,11 +47,11 @@
    {{if .ProfilePhoto.ID}} - {{if and (eq .ProfilePhoto.Visibility "private") (not .UserRelationship.IsPrivateGranted)}} + {{if and (eq .ProfilePhoto.Visibility "private") (not .UserRelationship.IsPrivateGranted) (not .UserRelationship.PublicAvatarConsent)}} - {{else if and (eq .ProfilePhoto.Visibility "friends") (not .UserRelationship.IsFriend)}} + {{else if and (eq .ProfilePhoto.Visibility "friends") (not .UserRelationship.IsFriend) (not .UserRelationship.PublicAvatarConsent)}} - {{else if and (eq .ProfilePhoto.Visibility "circle") (not .UserRelationship.IsInnerCirclePeer)}} + {{else if and (eq .ProfilePhoto.Visibility "circle") (not .UserRelationship.IsInnerCirclePeer) (not .UserRelationship.PublicAvatarConsent)}} {{else}} @@ -68,11 +68,11 @@
    {{if .ProfilePhoto.ID}} - {{if and (eq .ProfilePhoto.Visibility "private") (not .UserRelationship.IsPrivateGranted)}} + {{if and (eq .ProfilePhoto.Visibility "private") (not .UserRelationship.IsPrivateGranted) (not .UserRelationship.PublicAvatarConsent)}} - {{else if and (eq .ProfilePhoto.Visibility "friends") (not .UserRelationship.IsFriend)}} + {{else if and (eq .ProfilePhoto.Visibility "friends") (not .UserRelationship.IsFriend) (not .UserRelationship.PublicAvatarConsent)}} - {{else if and (eq .ProfilePhoto.Visibility "circle") (not .UserRelationship.IsInnerCirclePeer)}} + {{else if and (eq .ProfilePhoto.Visibility "circle") (not .UserRelationship.IsInnerCirclePeer) (not .UserRelationship.PublicAvatarConsent)}} {{else}} @@ -89,11 +89,11 @@
    {{if .ProfilePhoto.ID}} - {{if and (eq .ProfilePhoto.Visibility "private") (not .UserRelationship.IsPrivateGranted)}} + {{if and (eq .ProfilePhoto.Visibility "private") (not .UserRelationship.IsPrivateGranted) (not .UserRelationship.PublicAvatarConsent)}} - {{else if and (eq .ProfilePhoto.Visibility "friends") (not .UserRelationship.IsFriend)}} + {{else if and (eq .ProfilePhoto.Visibility "friends") (not .UserRelationship.IsFriend) (not .UserRelationship.PublicAvatarConsent)}} - {{else if and (eq .ProfilePhoto.Visibility "circle") (not .UserRelationship.IsInnerCirclePeer)}} + {{else if and (eq .ProfilePhoto.Visibility "circle") (not .UserRelationship.IsInnerCirclePeer) (not .UserRelationship.PublicAvatarConsent)}} {{else}}