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