Likes on Comments, and other minor improvements

* Add "Like" buttons to comments and forum posts.
* Make "private" profiles more private (logged-in users see only their profile
  pic, display name, and can friend request or message, if they are not approved
  friends of the private user)
* Add "logged-out view" visibility setting to profiles: to share a link to your
  page on other sites. Opt-in setting - default is login required to view your
  public profile page.
* CSRF cookie fix.
* Updated FAQ & Privacy pages.
This commit is contained in:
Noah 2022-08-29 20:00:15 -07:00
parent d2700490cc
commit 8419958b25
20 changed files with 250 additions and 74 deletions

View File

@ -29,7 +29,7 @@ const (
const ( const (
BcryptCost = 14 BcryptCost = 14
SessionCookieName = "session_id" SessionCookieName = "session_id"
CSRFCookieName = "csrf_token" CSRFCookieName = "xsrf_token"
CSRFInputName = "_csrf" // html input name CSRFInputName = "_csrf" // html input name
SessionCookieMaxAge = 60 * 60 * 24 * 30 SessionCookieMaxAge = 60 * 60 * 24 * 30
SessionRedisKeyFormat = "session/%s" SessionRedisKeyFormat = "session/%s"

View File

@ -30,12 +30,26 @@ func Profile() http.HandlerFunc {
return return
} }
// Get the current user (if logged in). // Forcing an external view? (preview of logged-out profile view for visibility=external accounts)
if r.FormValue("view") == "external" {
vars := map[string]interface{}{
"User": user,
"IsPrivate": true,
"IsExternalView": true,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
return
}
// Get the current user (if logged in). If not, check for external view.
currentUser, err := session.CurrentUser(r) currentUser, err := session.CurrentUser(r)
if err != nil { if err != nil {
// The viewer is not logged in, bail now with the basic profile page. If this // The viewer is not logged in, bail now with the basic profile page. If this
// user is private, redirect to login. // user doesn't allow external viewers, redirect to login page.
if user.Visibility == models.UserVisibilityPrivate { if user.Visibility != models.UserVisibilityExternal {
session.FlashError(w, r, "You must be signed in to view this page.") session.FlashError(w, r, "You must be signed in to view this page.")
templates.Redirect(w, "/login?next="+url.QueryEscape(r.URL.String())) templates.Redirect(w, "/login?next="+url.QueryEscape(r.URL.String()))
return return

View File

@ -93,14 +93,16 @@ func Settings() http.HandlerFunc {
case "preferences": case "preferences":
var ( var (
explicit = r.PostFormValue("explicit") == "true" explicit = r.PostFormValue("explicit") == "true"
private = r.PostFormValue("private") == "true" visibility = models.UserVisibility(r.PostFormValue("visibility"))
) )
user.Explicit = explicit user.Explicit = explicit
if private {
user.Visibility = models.UserVisibilityPrivate
} else {
user.Visibility = models.UserVisibilityPublic user.Visibility = models.UserVisibilityPublic
for _, cmp := range models.UserVisibilityOptions {
if visibility == cmp {
user.Visibility = visibility
}
} }
if err := user.Save(); err != nil { if err := user.Save(); err != nil {

View File

@ -5,8 +5,6 @@ import (
"errors" "errors"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"code.nonshy.com/nonshy/website/pkg/log"
) )
// Envelope is the standard JSON response envelope. // Envelope is the standard JSON response envelope.
@ -27,8 +25,6 @@ func ParseJSON(r *http.Request, v interface{}) error {
return err return err
} }
log.Error("body: %+v", body)
// Parse params from JSON. // Parse params from JSON.
if err := json.Unmarshal(body, v); err != nil { if err := json.Unmarshal(body, v); err != nil {
return err return err

View File

@ -16,6 +16,7 @@ func Likes() http.HandlerFunc {
TableName string `json:"name"` TableName string `json:"name"`
TableID uint64 `json:"id"` TableID uint64 `json:"id"`
Unlike bool `json:"unlike,omitempty"` Unlike bool `json:"unlike,omitempty"`
Referrer string `json:"page"`
} }
// Response JSON schema. // Response JSON schema.
@ -51,8 +52,18 @@ func Likes() http.HandlerFunc {
return return
} }
// Sanity check things. The page= param (Referrer) must be a relative URL, the path
// is useful for "liked your comment" notifications to supply the Link URL for the
// notification.
if len(req.Referrer) > 0 && req.Referrer[0] != '/' {
req.Referrer = ""
}
// Who do we notify about this like? // Who do we notify about this like?
var targetUser *models.User var (
targetUser *models.User
notificationMessage string
)
switch req.TableName { switch req.TableName {
case "photos": case "photos":
if photo, err := models.GetPhoto(req.TableID); err == nil { if photo, err := models.GetPhoto(req.TableID); err == nil {
@ -70,6 +81,15 @@ func Likes() http.HandlerFunc {
} else { } else {
log.Error("For like on users table: didn't find user %d: %s", req.TableID, err) log.Error("For like on users table: didn't find user %d: %s", req.TableID, err)
} }
case "comments":
log.Error("subject is users, find %d", req.TableID)
if comment, err := models.GetComment(req.TableID); err == nil {
targetUser = &comment.User
notificationMessage = comment.Message
log.Warn("found user %s", targetUser.Username)
} else {
log.Error("For like on users table: didn't find user %d: %s", req.TableID, err)
}
} }
// Is the table likeable? // Is the table likeable?
@ -108,6 +128,8 @@ func Likes() http.HandlerFunc {
Type: models.NotificationLike, Type: models.NotificationLike,
TableName: req.TableName, TableName: req.TableName,
TableID: req.TableID, TableID: req.TableID,
Message: notificationMessage,
Link: req.Referrer,
} }
if err := models.CreateNotification(notif); err != nil { if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create Likes notification: %s", err) log.Error("Couldn't create Likes notification: %s", err)

View File

@ -14,7 +14,7 @@ import (
var ThreadPathRegexp = regexp.MustCompile(`^/forum/thread/(\d+)$`) var ThreadPathRegexp = regexp.MustCompile(`^/forum/thread/(\d+)$`)
// Thread view for a specific board index. // Thread view for the comment thread body of a forum post.
func Thread() http.HandlerFunc { func Thread() http.HandlerFunc {
tmpl := templates.Must("forum/thread.html") tmpl := templates.Must("forum/thread.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -74,6 +74,13 @@ func Thread() http.HandlerFunc {
return return
} }
// Get the like map for these comments.
commentIDs := []uint64{}
for _, com := range comments {
commentIDs = append(commentIDs, com.ID)
}
commentLikeMap := models.MapLikes(currentUser, "comments", commentIDs)
// Is the current user subscribed to notifications on this thread? // Is the current user subscribed to notifications on this thread?
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID) _, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
@ -81,6 +88,7 @@ func Thread() http.HandlerFunc {
"Forum": forum, "Forum": forum,
"Thread": thread, "Thread": thread,
"Comments": comments, "Comments": comments,
"LikeMap": commentLikeMap,
"Pager": pager, "Pager": pager,
"IsSubscribed": isSubscribed, "IsSubscribed": isSubscribed,
} }

View File

@ -76,6 +76,13 @@ func View() http.HandlerFunc {
log.Error("Couldn't list comments for photo %d: %s", photo.ID, err) log.Error("Couldn't list comments for photo %d: %s", photo.ID, err)
} }
// Get the like map for these comments.
commentIDs := []uint64{}
for _, com := range comments {
commentIDs = append(commentIDs, com.ID)
}
commentLikeMap := models.MapLikes(currentUser, "comments", commentIDs)
// Is the current user subscribed to notifications on this thread? // Is the current user subscribed to notifications on this thread?
_, isSubscribed := models.IsSubscribed(currentUser, "photos", photo.ID) _, isSubscribed := models.IsSubscribed(currentUser, "photos", photo.ID)
@ -86,6 +93,7 @@ func View() http.HandlerFunc {
"LikeMap": likeMap, "LikeMap": likeMap,
"CommentMap": commentMap, "CommentMap": commentMap,
"Comments": comments, "Comments": comments,
"CommentLikeMap": commentLikeMap,
"IsSubscribed": isSubscribed, "IsSubscribed": isSubscribed,
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {

View File

@ -58,6 +58,7 @@ func MakeCSRFCookie(r *http.Request, w http.ResponseWriter) string {
Name: config.CSRFCookieName, Name: config.CSRFCookieName,
Value: token, Value: token,
HttpOnly: true, HttpOnly: true,
Path: "/",
} }
// log.Debug("MakeCSRFCookie: giving cookie value %s to user", token) // log.Debug("MakeCSRFCookie: giving cookie value %s to user", token)
http.SetCookie(w, cookie) http.SetCookie(w, cookie)

View File

@ -20,6 +20,7 @@ type Like struct {
var LikeableTables = map[string]interface{}{ var LikeableTables = map[string]interface{}{
"photos": nil, "photos": nil,
"users": nil, "users": nil,
"comments": nil,
} }
// AddLike to something. // AddLike to something.

View File

@ -40,9 +40,17 @@ type UserVisibility string
const ( const (
UserVisibilityPublic UserVisibility = "public" UserVisibilityPublic UserVisibility = "public"
UserVisibilityExternal = "external"
UserVisibilityPrivate = "private" UserVisibilityPrivate = "private"
) )
// All visibility options.
var UserVisibilityOptions = []UserVisibility{
UserVisibilityPublic,
UserVisibilityExternal,
UserVisibilityPrivate,
}
// Preload related tables for the user (classmethod). // Preload related tables for the user (classmethod).
func (u *User) Preload() *gorm.DB { func (u *User) Preload() *gorm.DB {
return DB.Preload("ProfileField").Preload("ProfilePhoto") return DB.Preload("ProfileField").Preload("ProfilePhoto")

View File

@ -51,6 +51,7 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
// Touching the user drop-down button toggles it. // Touching the user drop-down button toggles it.
if (userMenu !== null) {
userMenu.addEventListener("touchstart", (e) => { userMenu.addEventListener("touchstart", (e) => {
// On mobile/tablet screens they had to hamburger menu their way here anyway, let it thru. // On mobile/tablet screens they had to hamburger menu their way here anyway, let it thru.
if (screen.width < 1024) { if (screen.width < 1024) {
@ -64,6 +65,7 @@ document.addEventListener('DOMContentLoaded', () => {
userMenu.classList.add(activeClass); userMenu.classList.add(activeClass);
} }
}); });
}
// Touching a link from the user menu lets it click thru. // Touching a link from the user menu lets it click thru.
(document.querySelectorAll(".navbar-dropdown") || []).forEach(node => { (document.querySelectorAll(".navbar-dropdown") || []).forEach(node => {

View File

@ -6,6 +6,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Bind to the like buttons. // Bind to the like buttons.
(document.querySelectorAll(".nonshy-like-button") || []).forEach(node => { (document.querySelectorAll(".nonshy-like-button") || []).forEach(node => {
node.addEventListener("click", (e) => { node.addEventListener("click", (e) => {
e.preventDefault();
if (busy) return; if (busy) return;
let $icon = node.querySelector(".icon"), let $icon = node.querySelector(".icon"),
@ -36,6 +37,7 @@ document.addEventListener('DOMContentLoaded', () => {
"name": tableName, // TODO "name": tableName, // TODO
"id": parseInt(tableID), "id": parseInt(tableID),
"unlike": !liking, "unlike": !liking,
"page": window.location.pathname + window.location.search + window.location.hash,
}), }),
}) })
.then((response) => response.json()) .then((response) => response.json())

View File

@ -201,6 +201,12 @@
{{end}} {{end}}
{{else if eq .TableName "users"}} {{else if eq .TableName "users"}}
profile page. profile page.
{{else if eq .TableName "comments"}}
{{if .Link}}
<a href="{{.Link}}">comment</a>:
{{else}}
comment.
{{end}}
{{else}} {{else}}
{{.TableName}}. {{.TableName}}.
{{end}} {{end}}

View File

@ -1,7 +1,7 @@
{{define "title"}}{{.User.Username}}{{end}} {{define "title"}}{{.User.Username}}{{end}}
{{define "content"}} {{define "content"}}
<div class="container"> <div class="container">
<section class="hero {{if .LoggedIn}}is-info{{else}}is-light is-bold{{end}}"> <section class="hero {{if and .LoggedIn (not .IsPrivate)}}is-info{{else}}is-light is-bold{{end}}">
<div class="hero-body"> <div class="hero-body">
<div class="container"> <div class="container">
<div class="columns"> <div class="columns">
@ -14,7 +14,7 @@
{{end}} {{end}}
<!-- CurrentUser can upload a new profile pic --> <!-- CurrentUser can upload a new profile pic -->
{{if and .LoggedIn (eq .CurrentUser.ID .User.ID)}} {{if and .LoggedIn (eq .CurrentUser.ID .User.ID) (not .IsPrivate)}}
<span class="corner"> <span class="corner">
<button class="button is-small p-1 is-success"> <button class="button is-small p-1 is-success">
<a href="/photo/upload?intent=profile_pic" <a href="/photo/upload?intent=profile_pic"
@ -36,7 +36,7 @@
({{.User.Status}}) ({{.User.Status}})
</h2> </h2>
{{end}} {{end}}
{{if not .LoggedIn}} {{if or (not .LoggedIn) .IsPrivate}}
<h2 class="subtitle">is on {{PrettyTitle}}, a social network for nudists &amp; exhibitionists.</h2> <h2 class="subtitle">is on {{PrettyTitle}}, a social network for nudists &amp; exhibitionists.</h2>
<p> <p>
{{PrettyTitle}} is a new social network for <strong>real</strong> nudists and exhibionists. {{PrettyTitle}} is a new social network for <strong>real</strong> nudists and exhibionists.
@ -47,7 +47,7 @@
{{end}} {{end}}
</div> </div>
{{if .LoggedIn}} {{if and .LoggedIn (not .IsPrivate)}}
<div class="column is-narrow"> <div class="column is-narrow">
<div class="box"> <div class="box">
<div> <div>
@ -97,7 +97,7 @@
{{end}}<!-- if .LoggedIn --> {{end}}<!-- if .LoggedIn -->
</div> </div>
{{if .LoggedIn}} {{if and .LoggedIn (not .IsExternalView)}}
<div class="columns is-centered is-gapless"> <div class="columns is-centered is-gapless">
<div class="column is-narrow has-text-centered"> <div class="column is-narrow has-text-centered">
<form action="/friends/add" method="POST"> <form action="/friends/add" method="POST">
@ -134,6 +134,7 @@
</div> </div>
<!-- Like button --> <!-- Like button -->
{{if not .IsPrivate}}
{{$Like := .LikeMap.Get .User.ID}} {{$Like := .LikeMap.Get .User.ID}}
<div class="column is-narrow has-text-centered"> <div class="column is-narrow has-text-centered">
<button type="button" class="button is-fullwidth nonshy-like-button" <button type="button" class="button is-fullwidth nonshy-like-button"
@ -148,6 +149,7 @@
</span> </span>
</button> </button>
</div> </div>
{{end}}
<div class="column is-narrow has-text-centered"> <div class="column is-narrow has-text-centered">
<form action="/users/block" method="POST"> <form action="/users/block" method="POST">
@ -183,7 +185,7 @@
</div> </div>
</section> </section>
{{if not .LoggedIn}} {{if or (not .LoggedIn) .IsPrivate}}
<div class="py-6"></div> <div class="py-6"></div>
{{else if .IsPrivate}} {{else if .IsPrivate}}
<div class="block p-4"> <div class="block p-4">

View File

@ -220,18 +220,46 @@
<div class="card-content"> <div class="card-content">
<div class="field"> <div class="field">
<label class="label">Private Profile</label> <label class="label">Profile Visibility</label>
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" <input type="radio"
name="private" name="visibility"
value="true" value="public"
{{if eq .CurrentUser.Visibility "public"}}checked{{end}}>
Public + Login Required
</label>
<p class="help">
The default is that users must be logged-in to even look at your profile
page. If your profile URL (/u/{{.CurrentUser.Username}}) is visited by a
logged-out browser, they are prompted to log in.
</p>
<label class="checkbox mt-2">
<input type="radio"
name="visibility"
value="external"
{{if eq .CurrentUser.Visibility "external"}}checked{{end}}>
Public + Limited Logged-out View
</label>
<p class="help">
Your profile is fully visible to logged-in users, but if a logged-out browser
visits your page they will see a very minimal view: only your profile picture
and display name are shown.
<a href="/u/{{.CurrentUser.Username}}?view=external" target="_blank">Preview <i class="fa fa-external-link"></i></a>
</p>
<label class="checkbox mt-2">
<input type="radio"
name="visibility"
value="private"
{{if eq .CurrentUser.Visibility "private"}}checked{{end}}> {{if eq .CurrentUser.Visibility "private"}}checked{{end}}>
Mark my profile page as "private" Mark my profile page as "private"
</label> </label>
<p class="help"> <p class="help">
If you check this box then only friends who you have approved are able to If you check this box then only friends who you have approved are able to
see your profile page and gallery. Your gallery photos also will NOT appear see your profile page and gallery. Your gallery photos also will NOT appear
on the Site Gallery page. on the Site Gallery page. If your profile page is visited by a logged-out
viewer, they are prompted to log in.
</p> </p>
</div> </div>

View File

@ -42,6 +42,37 @@
until your profile has been certified. until your profile has been certified.
</p> </p>
<h3>What are the visibility options for my profile page?</h3>
<p>
There are currently three different choices for your profile visibility on your
<a href="/settings">Settings</a> page:
</p>
<ul>
<li>
The <strong>default</strong> visibility is <strong class="has-text-success-dark">"Public + Login Required."</strong> Users must be
logged-in to an account in order to see anything about your profile page - if an
external (logged out) browser visits your profile URL, they will be redirected to
log in to an account first.
</li>
<li>
You may optionally go <em>more</em> public with a <strong class="has-text-warning-dark">"Limited Logged-out View."</strong>
This enables your profile URL (e.g.,
{{if .LoggedIn}}<a href="/u/{{.CurrentUser.Username}}?view=external">/u/{{.CurrentUser.Username}}</a>{{else}}/u/username{{end}})
to show a <em>basic</em> page (with your square profile picture and display name) to
logged-out browsers. This may be useful if you wish to link to your page from an external
site (e.g. your Twitter page) and present new users with a better experience than just
a redirect to login page.
</li>
<li>
You may <strong class="has-text-private">"Mark my profile as 'private'"</strong> to
be private even from other logged-in members who are not on your Friends
list. Logged-in users will see only your square profile picture and display
name, and be able only to send you a friend request or a message.
</li>
</ul>
<h1>Photo FAQs</h1> <h1>Photo FAQs</h1>
<h3>Do I have to post my nudes here?</h3> <h3>Do I have to post my nudes here?</h3>

View File

@ -157,6 +157,21 @@
</span> </span>
</div> </div>
<div class="column is-narrow">
{{$Like := $Root.LikeMap.Get .ID}}
<a href="#" class="has-text-dark nonshy-like-button"
data-table-name="comments" data-table-id="{{.ID}}"
title="Like">
<span class="icon{{if $Like.UserLikes}} has-text-danger{{end}}"><i class="fa fa-heart"></i></span>
<span class="nonshy-likes">
Like
{{if gt $Like.Count 0}}
({{$Like.Count}})
{{end}}
</span>
</a>
</div>
<div class="column is-narrow"> <div class="column is-narrow">
<a href="/contact?intent=report&subject=report.comment&id={{.ID}}" class="has-text-dark"> <a href="/contact?intent=report&subject=report.comment&id={{.ID}}" class="has-text-dark">
<span class="icon"><i class="fa fa-flag"></i></span> <span class="icon"><i class="fa fa-flag"></i></span>

View File

@ -21,15 +21,9 @@
<p> <p>
This website was designed by a life-long nudist, exhibitionist and software engineer to create This website was designed by a life-long nudist, exhibitionist and software engineer to create
a safe space for like-minded individuals online, especially in the modern online political a safe space for like-minded individuals online. This website is open to <em>all</em> nudists
climate and after Tumblr, Pornhub and other social networks had begun clamping down and kicking and exhibitionists (18+), but I understand that not all nudists want to see any sexual content, so
off all the nudists from their platforms. this site provides some controls to support both camps:
</p>
<p>
This website is open to <em>all</em> nudists and exhibitionists, but I understand that not all
nudists want to see any sexual content, so this site provides some controls to support
both camps:
</p> </p>
<ul> <ul>
@ -44,6 +38,11 @@
</li> </li>
</ul> </ul>
<p>
You can read more <a href="/about">about this website</a> and check out the <a href="/faq">FAQ</a>
page for more information.
</p>
<h1>Site Rules</h1> <h1>Site Rules</h1>
<ul> <ul>

View File

@ -241,6 +241,21 @@
</span> </span>
</div> </div>
<div class="column is-narrow">
{{$Like := $Root.CommentLikeMap.Get .ID}}
<a href="#" class="has-text-dark nonshy-like-button"
data-table-name="comments" data-table-id="{{.ID}}"
title="Like">
<span class="icon{{if $Like.UserLikes}} has-text-danger{{end}}"><i class="fa fa-heart"></i></span>
<span class="nonshy-likes">
Like
{{if gt $Like.Count 0}}
({{$Like.Count}})
{{end}}
</span>
</a>
</div>
<div class="column is-narrow"> <div class="column is-narrow">
<a href="/contact?intent=report&subject=report.comment&id={{.ID}}" class="has-text-dark"> <a href="/contact?intent=report&subject=report.comment&id={{.ID}}" class="has-text-dark">
<span class="icon"><i class="fa fa-flag"></i></span> <span class="icon"><i class="fa fa-flag"></i></span>

View File

@ -25,7 +25,7 @@
</p> </p>
<p> <p>
This page was last updated on <strong>August 26, 2022.</strong> This page was last updated on <strong>August 29, 2022.</strong>
</p> </p>
<p> <p>
@ -43,6 +43,10 @@
</p> </p>
<ul> <ul>
<li>
By default, your profile page on {{PrettyTitle}} may <strong>only</strong> be seen
by logged-in members of the website.
</li>
<li> <li>
You may mark your entire profile as "Private" which limits some of the contact you You may mark your entire profile as "Private" which limits some of the contact you
may receive: may receive:
@ -58,6 +62,12 @@
</li> </li>
</ul> </ul>
</li> </li>
<li>
Optionally, you may mark your Public profile to allow a limited "logged out" view which
shows only your square profile picture and display name. This may be useful to link to
your profile from external sites (like Twitter) so the visitor isn't just redirected to a
"login required" page.
</li>
<li> <li>
Profile photos have visibility settings including Public, Friends-only or Private: Profile photos have visibility settings including Public, Friends-only or Private:
<ul> <ul>
@ -77,6 +87,12 @@
</li> </li>
</ul> </ul>
</li> </li>
<li>
<strong>Notice:</strong> the square default profile picture that appears on your page
will always be visible to all logged-in users. The full size version on your Gallery
page may be restricted to friends or private, but the square cropped version that appears
next to your username on many parts of the website is always seen by logged-in users.
</li>
</ul> </ul>
<h3>Site-Wide Photo Gallery</h3> <h3>Site-Wide Photo Gallery</h3>