Private Profiles & Misc Improvements

* Add setting to mark profile as "private"
* If a profile is private you can't see their profile page or user photo
  gallery unless you are friends (or admin)
* The Site Gallery never shows pictures from private profiles.
* Add HTML5 drag/drop upload support for photo gallery.
* Suppress SQL logging except in debug mode.
* Clean up extra logs.
This commit is contained in:
Noah 2022-08-21 17:29:39 -07:00
parent 09d61aa5c7
commit 7f96edf95d
15 changed files with 163 additions and 42 deletions

View File

@ -145,8 +145,11 @@ func initdb(c *cli.Context) {
// Load the settings.json // Load the settings.json
config.LoadSettings() config.LoadSettings()
var gormcfg = &gorm.Config{ var gormcfg = &gorm.Config{}
Logger: logger.Default.LogMode(logger.Info), if c.Bool("debug") {
gormcfg = &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
}
} }
// Initialize the database. // Initialize the database.

View File

@ -3,7 +3,6 @@ package account
import ( import (
"net/http" "net/http"
"git.kirsle.net/apps/gosocial/pkg/log"
"git.kirsle.net/apps/gosocial/pkg/templates" "git.kirsle.net/apps/gosocial/pkg/templates"
) )
@ -11,7 +10,6 @@ import (
func Dashboard() http.HandlerFunc { func Dashboard() http.HandlerFunc {
tmpl := templates.Must("account/dashboard.html") tmpl := templates.Must("account/dashboard.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Error("Dashboard called")
if err := tmpl.Execute(w, r, nil); err != nil { if err := tmpl.Execute(w, r, nil); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return

View File

@ -37,6 +37,8 @@ func Profile() http.HandlerFunc {
return return
} }
var isSelf = currentUser.ID == user.ID
// Banned or disabled? Only admin can view then. // Banned or disabled? Only admin can view then.
if user.Status != models.UserStatusActive && !currentUser.IsAdmin { if user.Status != models.UserStatusActive && !currentUser.IsAdmin {
templates.NotFoundPage(w, r) templates.NotFoundPage(w, r)
@ -49,9 +51,16 @@ func Profile() http.HandlerFunc {
return return
} }
// Are they friends? And/or is this user private?
var (
isFriend = models.FriendStatus(currentUser.ID, user.ID)
isPrivate = !currentUser.IsAdmin && !isSelf && user.Visibility == models.UserVisibilityPrivate && isFriend != "approved"
)
vars := map[string]interface{}{ vars := map[string]interface{}{
"User": user, "User": user,
"IsFriend": models.FriendStatus(currentUser.ID, user.ID), "IsFriend": isFriend,
"IsPrivate": isPrivate,
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {

View File

@ -93,9 +93,15 @@ 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"
) )
user.Explicit = explicit user.Explicit = explicit
if private {
user.Visibility = models.UserVisibilityPrivate
} else {
user.Visibility = models.UserVisibilityPublic
}
if err := user.Save(); err != nil { if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err) session.FlashError(w, r, "Failed to save user to database: %s", err)

View File

@ -5,7 +5,6 @@ import (
"regexp" "regexp"
"git.kirsle.net/apps/gosocial/pkg/config" "git.kirsle.net/apps/gosocial/pkg/config"
"git.kirsle.net/apps/gosocial/pkg/log"
"git.kirsle.net/apps/gosocial/pkg/models" "git.kirsle.net/apps/gosocial/pkg/models"
"git.kirsle.net/apps/gosocial/pkg/session" "git.kirsle.net/apps/gosocial/pkg/session"
"git.kirsle.net/apps/gosocial/pkg/templates" "git.kirsle.net/apps/gosocial/pkg/templates"
@ -52,6 +51,17 @@ func UserPhotos() http.HandlerFunc {
return return
} }
// Is this user private and we're not friends?
var (
areFriends = models.AreFriends(user.ID, currentUser.ID)
isPrivate = user.Visibility == models.UserVisibilityPrivate && !areFriends
)
if isPrivate && !currentUser.IsAdmin && !isOwnPhotos {
session.FlashError(w, r, "This user's profile page and photo gallery are private.")
templates.Redirect(w, "/u/"+user.Username)
return
}
// What set of visibilities to query? // What set of visibilities to query?
visibility := []models.PhotoVisibility{models.PhotoPublic} visibility := []models.PhotoVisibility{models.PhotoPublic}
if isOwnPhotos || currentUser.IsAdmin { if isOwnPhotos || currentUser.IsAdmin {
@ -73,7 +83,6 @@ func UserPhotos() http.HandlerFunc {
Sort: "created_at desc", Sort: "created_at desc",
} }
pager.ParsePage(r) pager.ParsePage(r)
log.Error("Pager: %+v", pager)
photos, err := models.PaginateUserPhotos(user.ID, visibility, explicit, pager) photos, err := models.PaginateUserPhotos(user.ID, visibility, explicit, pager)
// Get the count of explicit photos if we are not viewing explicit photos. // Get the count of explicit photos if we are not viewing explicit photos.

View File

@ -16,7 +16,7 @@ func init() {
Theme: golog.DarkTheme, Theme: golog.DarkTheme,
}) })
log.Config.Level = golog.DebugLevel log.Config.Level = golog.InfoLevel
} }
// SetDebug toggles debug level logging. // SetDebug toggles debug level logging.

View File

@ -152,6 +152,11 @@ func PaginateGalleryPhotos(userID uint64, adminView bool, explicitOK bool, pager
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND certified = true)", "EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND certified = true)",
) )
// Exclude private users' photos.
wheres = append(wheres,
"NOT EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND visibility = 'private')",
)
// Admin view: get ALL PHOTOS on the site, period. // Admin view: get ALL PHOTOS on the site, period.
if adminView { if adminView {
query = DB query = DB

View File

@ -19,9 +19,9 @@ type User struct {
Username string `gorm:"uniqueIndex"` Username string `gorm:"uniqueIndex"`
Email string `gorm:"uniqueIndex"` Email string `gorm:"uniqueIndex"`
HashedPassword string HashedPassword string
IsAdmin bool `gorm:"index"` IsAdmin bool `gorm:"index"`
Status UserStatus `gorm:"index"` // active, disabled Status UserStatus `gorm:"index"` // active, disabled
Visibility string `gorm:"index"` // public, private Visibility UserVisibility `gorm:"index"` // public, private
Name *string Name *string
Birthdate time.Time Birthdate time.Time
Certified bool Certified bool
@ -36,6 +36,13 @@ type User struct {
ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"` ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"`
} }
type UserVisibility string
const (
UserVisibilityPublic UserVisibility = "public"
UserVisibilityPrivate = "private"
)
// 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")
@ -60,9 +67,10 @@ func CreateUser(username, email, password string) (*User, error) {
} }
u := &User{ u := &User{
Username: username, Username: username,
Email: email, Email: email,
Status: UserStatusActive, Status: UserStatusActive,
Visibility: UserVisibilityPublic,
} }
if err := u.HashPassword(password); err != nil { if err := u.HashPassword(password); err != nil {

View File

@ -56,7 +56,7 @@ func LoadOrNew(r *http.Request) *Session {
key := fmt.Sprintf(config.SessionRedisKeyFormat, sess.UUID) key := fmt.Sprintf(config.SessionRedisKeyFormat, sess.UUID)
err = redis.Get(key, sess) err = redis.Get(key, sess)
log.Error("LoadOrNew: raw from Redis: %+v", sess) // log.Error("LoadOrNew: raw from Redis: %+v", sess)
if err != nil { if err != nil {
log.Error("session.LoadOrNew: didn't find %s in Redis: %s", err) log.Error("session.LoadOrNew: didn't find %s in Redis: %s", err)
} }

View File

@ -29,6 +29,7 @@
<div class="column"> <div class="column">
<h1 class="title"> <h1 class="title">
{{.User.NameOrUsername}} {{.User.NameOrUsername}}
{{if eq .User.Visibility "private"}}<sup class="fa fa-mask ml-2 is-size-6" title="Private Profile"></sup>{{end}}
</h1> </h1>
{{if ne .User.Status "active"}} {{if ne .User.Status "active"}}
<h2 class="subtitle"> <h2 class="subtitle">
@ -164,6 +165,14 @@
</div> </div>
</section> </section>
{{if .IsPrivate}}
<div class="block p-4">
<div class="notification block is-warning">
<i class="fa fa-mask"></i> This member's profile page is <strong>private.</strong> You may send them
a friend request, and only if approved, you may then view their profile page and photo gallery.
</div>
</div>
{{else}}
<div class="block p-4"> <div class="block p-4">
<div class="tabs is-boxed"> <div class="tabs is-boxed">
<ul> <ul>
@ -352,7 +361,8 @@
</div> </div>
{{end}} {{end}}
</div> </div>
</div> </div><!-- /columns-->
</div> </div>
{{end}}<!-- not IsPrivate -->
</div> </div>
{{end}} {{end}}

View File

@ -183,7 +183,12 @@
</div> </div>
<div class="media-content"> <div class="media-content">
<p class="title is-4"> <p class="title is-4">
<a href="/u/{{.Username}}" class="has-text-dark">{{.NameOrUsername}}</a> <a href="/u/{{.Username}}" class="has-text-dark">
{{.NameOrUsername}}
</a>
{{if eq .Visibility "private"}}
<sup class="fa fa-mask is-size-7" title="Private Profile"></sup>
{{end}}
</p> </p>
<p class="subtitle is-6 mb-2"> <p class="subtitle is-6 mb-2">
<span class="icon"><i class="fa fa-user"></i></span> <span class="icon"><i class="fa fa-user"></i></span>

View File

@ -219,6 +219,22 @@
</header> </header>
<div class="card-content"> <div class="card-content">
<div class="field">
<label class="label">Private Profile</label>
<label class="checkbox">
<input type="checkbox"
name="private"
value="true"
{{if eq .CurrentUser.Visibility "private"}}checked{{end}}>
Mark my profile page as "private"
</label>
<p class="help">
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
on the Site Gallery page.
</p>
</div>
<div class="field"> <div class="field">
<label class="label">Explicit Content Filter</label> <label class="label">Explicit Content Filter</label>
<label class="checkbox"> <label class="checkbox">

View File

@ -35,7 +35,7 @@
<ul> <ul>
<li> <li>
For <strong>nudists:</strong> a default setting on your profile will hide 'explicit content' For <strong>nudists:</strong> a default setting on your profile will hide 'explicit content'
from users' profile pages and you will not see the explicit web forums either. from users' profile pages and you will not see the explicit web forum threads either.
</li> </li>
<li> <li>
For <strong>exhibitionists:</strong> you can toggle that setting to view explicit content For <strong>exhibitionists:</strong> you can toggle that setting to view explicit content
@ -44,6 +44,19 @@
</li> </li>
</ul> </ul>
<h1>Open Beta</h1>
<p>
This website is currently open for beta testing. It's not 100% complete yet, but is basically
functional. It currently supports profile pages, photo galleries, friend requests, Direct
Messages, a member directory to discover new users, and basic account management features.
</p>
<p>
Before the "1.0" launch it will include web forums which will provide a community space to
chat with and meet other members.
</p>
<h1>Site Rules</h1> <h1>Site Rules</h1>
<ul> <ul>
@ -72,25 +85,11 @@
</li> </li>
</ul> </ul>
<h1>Site Features</h1>
<p> <p>
This website is still a work in progress, but <em>eventually</em> it will have at least For more details, please check out the <a href="/tos">Terms of Service</a> as well as
the following features and functions: the <a href="/privacy">Privacy Policy</a>.
</p> </p>
<ul>
<li>
<strong>Web forums</strong> where you can write posts and meet your fellow members in the comments.
</li>
<li>
<strong>Profile pages</strong> where you can write a bit about yourself and upload some of your
nudist or exhibitionist pictures.
</li>
<li>
<strong>Direct messages</strong> where you can chat with other members on the site.
</li>
</ul>
</div> </div>
<div class="column is-one-quarter"> <div class="column is-one-quarter">

View File

@ -5,7 +5,12 @@
When User Gallery: .User is defined, .IsOwnPhotos may be. When User Gallery: .User is defined, .IsOwnPhotos may be.
--> -->
{{define "title"}} {{define "title"}}
{{if .IsSiteGallery}}Member Gallery{{else}}Photos of {{.User.Username}}{{end}} {{if .IsSiteGallery}}
Member Gallery
{{else}}
Photos of {{.User.Username}}
{{if eq .User.Visibility "private"}}<sup class="fa fa-mask ml-2 is-size-6" title="Private Profile"></sup>{{end}}
{{end}}
{{end}} {{end}}
<!-- Reusable card body --> <!-- Reusable card body -->

View File

@ -19,6 +19,16 @@
{{ $User := .CurrentUser }} {{ $User := .CurrentUser }}
<!-- Drag/Drop Modal -->
<div class="modal" id="drop-modal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box content has-text-centered">
<h1><i class="fa fa-upload mr-2"></i> Drop image to select it for upload</h1>
</div>
</div>
</div>
{{if .EditPhoto}} {{if .EditPhoto}}
<form action="/photo/edit" method="POST"> <form action="/photo/edit" method="POST">
<input type="hidden" name="id" value="{{.EditPhoto.ID}}"> <input type="hidden" name="id" value="{{.EditPhoto.ID}}">
@ -347,13 +357,13 @@
$fileName = document.querySelector("#fileName"), $fileName = document.querySelector("#fileName"),
$hiddenPreview = document.querySelector("#imagePreview"), $hiddenPreview = document.querySelector("#imagePreview"),
$previewBox = document.querySelector("#previewBox"), $previewBox = document.querySelector("#previewBox"),
$cropField = document.querySelector("#cropCoords"); $cropField = document.querySelector("#cropCoords"),
$dropArea = document.querySelector("#drop-modal")
$body = document.querySelector("body");
// Clear the answer in case of page reload. // Common handler for file selection, either via input
$cropField.value = ""; // field or drag/drop onto the page.
let onFile = (file) => {
$file.addEventListener("change", function() {
let file = this.files[0];
$fileName.innerHTML = file.name; $fileName.innerHTML = file.name;
// Read the image to show the preview on-page. // Read the image to show the preview on-page.
@ -411,7 +421,45 @@
} }
}); });
reader.readAsDataURL(file); reader.readAsDataURL(file);
};
// Set up drag/drop file upload events.
$body.addEventListener("dragenter", function(e) {
e.preventDefault();
e.stopPropagation();
$dropArea.classList.add("is-active");
});
$body.addEventListener("dragover", function(e) {
e.preventDefault();
e.stopPropagation();
$dropArea.classList.add("is-active");
});
$body.addEventListener("dragleave", function(e) {
e.preventDefault();
e.stopPropagation();
$dropArea.classList.remove("is-active");
});
$body.addEventListener("drop", function(e) {
e.preventDefault();
e.stopPropagation();
$dropArea.classList.remove("is-active");
// Grab the file.
let dt = e.dataTransfer;
let file = dt.files[0];
// Set the file on the input field too.
$file.files = dt.files;
onFile(file);
});
// Clear the answer in case of page reload.
$cropField.value = "";
$file.addEventListener("change", function() {
let file = this.files[0];
onFile(file);
}); });
}); });
{{end}} {{end}}