Usage Statistics and Website Demographics

Adds two new features to collect and show useful analytics.

Usage Statistics:
* Begin tracking daily active users who log in and interact with major features
  of the website each day, such as the chat room, forum and gallery.

Demographics page:
* For marketing, the home page now shows live statistics about the breakdown of
  content (explicit vs. non-explicit) on the site, and the /insights page gives
  a lot more data in detail.
* Show the percent split in photo gallery content and how many users opt-in or
  share explicit content on the site.
* Show high-level demographics of the members (by age range, gender, orientation)

Misc cleanup:
* Rearrange model list in data export to match the auto-create statements.
* In data exports, include the forum_memberships, push_notifications and
  usage_statistics tables.
This commit is contained in:
Noah Petherbridge 2024-09-11 19:28:52 -07:00
parent 8d9588b039
commit 2f31d678d0
18 changed files with 1020 additions and 31 deletions

View File

@ -83,6 +83,9 @@ const (
// Chat room status refresh interval. // Chat room status refresh interval.
ChatStatusRefreshInterval = 30 * time.Second ChatStatusRefreshInterval = 30 * time.Second
// Cache TTL for the demographics page.
DemographicsCacheTTL = time.Hour
) )
var ( var (

View File

@ -154,6 +154,13 @@ func Landing() http.HandlerFunc {
// of time where they can exist in chat but change their name on the site. // of time where they can exist in chat but change their name on the site.
worker.GetChatStatistics().SetOnlineNow(currentUser.Username) worker.GetChatStatistics().SetOnlineNow(currentUser.Username)
// Ping their chat login usage statistic.
go func() {
if err := models.LogDailyChatUser(currentUser); err != nil {
log.Error("LogDailyChatUser(%s): error logging this user's chat statistic: %s", currentUser.Username, err)
}
}()
// Redirect them to the chat room. // Redirect them to the chat room.
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss) templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss)
return return

View File

@ -97,6 +97,13 @@ func Thread() http.HandlerFunc {
// 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)
// Ping this user as having used the forums today.
go func() {
if err := models.LogDailyForumUser(currentUser); err != nil {
log.Error("LogDailyForumUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
}
}()
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"Forum": forum, "Forum": forum,
"Thread": thread, "Thread": thread,

View File

@ -0,0 +1,57 @@
package index
import (
"net/http"
"code.nonshy.com/nonshy/website/pkg/models/demographic"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// Demographics page (/insights) to show a peek at website demographics.
func Demographics() http.HandlerFunc {
tmpl := templates.Must("demographics.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
refresh = r.FormValue("refresh") == "true"
)
// Are we refreshing? Check if an admin is logged in.
if refresh {
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "You must be logged in to do that!")
templates.Redirect(w, r.URL.Path)
return
}
// Do the refresh?
if currentUser.IsAdmin {
_, err := demographic.Refresh()
if err != nil {
session.FlashError(w, r, "Refreshing the insights: %s", err)
}
}
templates.Redirect(w, r.URL.Path)
return
}
// Get website statistics to show on home page.
demo, err := demographic.Get()
if err != nil {
session.FlashError(w, r, "Couldn't get website statistics: %s", err)
templates.Redirect(w, "/")
return
}
vars := map[string]interface{}{
"Demographic": demo,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -5,6 +5,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models/demographic"
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
) )
@ -18,7 +19,17 @@ func Create() http.HandlerFunc {
return return
} }
if err := tmpl.Execute(w, r, nil); err != nil { // Get website statistics to show on home page.
demo, err := demographic.Get()
if err != nil {
log.Error("demographic.Get: %s", err)
}
vars := map[string]interface{}{
"Demographic": demo,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }

View File

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
@ -121,6 +122,13 @@ func SiteGallery() http.HandlerFunc {
likeMap := models.MapLikes(currentUser, "photos", photoIDs) likeMap := models.MapLikes(currentUser, "photos", photoIDs)
commentMap := models.MapCommentCounts("photos", photoIDs) commentMap := models.MapCommentCounts("photos", photoIDs)
// Ping this user as having used the forums today.
go func() {
if err := models.LogDailyGalleryUser(currentUser); err != nil {
log.Error("LogDailyGalleryUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
}
}()
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"IsSiteGallery": true, "IsSiteGallery": true,
"Photos": photos, "Photos": photos,

View File

@ -48,9 +48,8 @@ func LoginRequired(handler http.Handler) http.Handler {
// Ping LastLoginAt for long lived sessions, but not if impersonated. // Ping LastLoginAt for long lived sessions, but not if impersonated.
var pingLastLoginAt bool var pingLastLoginAt bool
if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown && !session.Impersonated(r) { if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown && !session.Impersonated(r) {
user.LastLoginAt = time.Now()
pingLastLoginAt = true pingLastLoginAt = true
if err := user.Save(); err != nil { if err := user.PingLastLoginAt(); err != nil {
log.Error("LoginRequired: couldn't refresh LastLoginAt for user %s: %s", user.Username, err) log.Error("LoginRequired: couldn't refresh LastLoginAt for user %s: %s", user.Username, err)
} }
} }

View File

@ -59,6 +59,7 @@ func DeleteUser(user *models.User) error {
{"IP Addresses", DeleteIPAddresses}, {"IP Addresses", DeleteIPAddresses},
{"Push Notifications", DeletePushNotifications}, {"Push Notifications", DeletePushNotifications},
{"Forum Memberships", DeleteForumMemberships}, {"Forum Memberships", DeleteForumMemberships},
{"Usage Statistics", DeleteUsageStatistics},
} }
for _, item := range todo { for _, item := range todo {
if err := item.Fn(user.ID); err != nil { if err := item.Fn(user.ID); err != nil {
@ -406,3 +407,13 @@ func DeleteForumMemberships(userID uint64) error {
).Delete(&models.ForumMembership{}) ).Delete(&models.ForumMembership{})
return result.Error return result.Error
} }
// DeleteUsageStatistics scrubs data for deleting a user.
func DeleteUsageStatistics(userID uint64) error {
log.Error("DeleteUser: DeleteUsageStatistics(%d)", userID)
result := models.DB.Where(
"user_id = ?",
userID,
).Delete(&models.UsageStatistic{})
return result.Error
}

View File

@ -0,0 +1,195 @@
// Package demographic handles periodic report pulling for high level website statistics.
//
// It powers the home page and insights page, where a prospective new user can get a peek inside
// the website to see the split between regular vs. explicit content and membership statistics.
//
// These database queries could get slow so the demographics are pulled and cached in this package.
package demographic
import (
"encoding/json"
"sort"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
)
// Demographic is the top level container struct with all the insights needed for front-end display.
type Demographic struct {
Computed bool
LastUpdated time.Time
Photo Photo
People People
}
// Photo statistics show the split between explicit and non-explicit content.
type Photo struct {
Total int64
NonExplicit int64
Explicit int64
}
// People statistics.
type People struct {
Total int64
ExplicitOptIn int64
ExplicitPhoto int64
ByAgeRange map[string]int64
ByGender map[string]int64
ByOrientation map[string]int64
}
// MemberDemographic of members.
type MemberDemographic struct {
Label string // e.g. age range "18-25" or gender
Count int64
Percent int
}
/**
* Dynamic calculation methods on the above types (percentages, etc.)
*/
func (d Demographic) PrettyPrint() string {
b, err := json.MarshalIndent(d, "", "\t")
if err != nil {
return err.Error()
}
return string(b)
}
func (p Photo) PercentExplicit() int {
if p.Total == 0 {
return 0
}
return int((float64(p.Explicit) / float64(p.Total)) * 100)
}
func (p Photo) PercentNonExplicit() int {
if p.Total == 0 {
return 0
}
return int((float64(p.NonExplicit) / float64(p.Total)) * 100)
}
func (p People) PercentExplicit() int {
if p.Total == 0 {
return 0
}
return int((float64(p.ExplicitOptIn) / float64(p.Total)) * 100)
}
func (p People) PercentExplicitPhoto() int {
if p.Total == 0 {
return 0
}
return int((float64(p.ExplicitPhoto) / float64(p.Total)) * 100)
}
func (p People) IterAgeRanges() []MemberDemographic {
var (
result = []MemberDemographic{}
values = []string{}
unique = map[string]struct{}{}
)
for age := range p.ByAgeRange {
if _, ok := unique[age]; !ok {
values = append(values, age)
}
unique[age] = struct{}{}
}
sort.Strings(values)
for _, age := range values {
var (
count = p.ByAgeRange[age]
pct int
)
if p.Total > 0 {
pct = int((float64(count) / float64(p.Total)) * 100)
}
result = append(result, MemberDemographic{
Label: age,
Count: p.ByAgeRange[age],
Percent: pct,
})
}
return result
}
func (p People) IterGenders() []MemberDemographic {
var (
result = []MemberDemographic{}
values = append(config.Gender, "")
unique = map[string]struct{}{}
)
for _, option := range values {
unique[option] = struct{}{}
}
for gender := range p.ByGender {
if _, ok := unique[gender]; !ok {
values = append(values, gender)
unique[gender] = struct{}{}
}
}
for _, gender := range values {
var (
count = p.ByGender[gender]
pct int
)
if p.Total > 0 {
pct = int((float64(count) / float64(p.Total)) * 100)
}
result = append(result, MemberDemographic{
Label: gender,
Count: p.ByGender[gender],
Percent: pct,
})
}
return result
}
func (p People) IterOrientations() []MemberDemographic {
var (
result = []MemberDemographic{}
values = append(config.Orientation, "")
unique = map[string]struct{}{}
)
for _, option := range values {
unique[option] = struct{}{}
}
for orientation := range p.ByOrientation {
if _, ok := unique[orientation]; !ok {
values = append(values, orientation)
unique[orientation] = struct{}{}
}
}
for _, gender := range values {
var (
count = p.ByOrientation[gender]
pct int
)
if p.Total > 0 {
pct = int((float64(count) / float64(p.Total)) * 100)
}
result = append(result, MemberDemographic{
Label: gender,
Count: p.ByOrientation[gender],
Percent: pct,
})
}
return result
}

View File

@ -0,0 +1,284 @@
package demographic
import (
"errors"
"sync"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
)
// Cached statistics (in case the queries are heavy to hit too often).
var (
cachedDemographic Demographic
cacheMu sync.Mutex
)
// Get the current cached demographics result.
func Get() (Demographic, error) {
// Do we have the results cached?
var result = cachedDemographic
if !result.Computed || time.Since(result.LastUpdated) > config.DemographicsCacheTTL {
cacheMu.Lock()
defer cacheMu.Unlock()
// If we have a race of threads: e.g. one request is pulling the stats and the second is locked.
// Check if we have an updated result from the first thread.
if time.Since(cachedDemographic.LastUpdated) < config.DemographicsCacheTTL {
return cachedDemographic, nil
}
// Get the latest.
res, err := Generate()
if err != nil {
return result, err
}
cachedDemographic = res
}
return cachedDemographic, nil
}
// Refresh the demographics cache, pulling fresh results from the database every time.
func Refresh() (Demographic, error) {
cacheMu.Lock()
cachedDemographic = Demographic{}
cacheMu.Unlock()
return Get()
}
// Generate the demographics result.
func Generate() (Demographic, error) {
if !config.Current.Database.IsPostgres {
return cachedDemographic, errors.New("this feature requires a PostgreSQL database")
}
result := Demographic{
Computed: true,
LastUpdated: time.Now(),
Photo: PhotoStatistics(),
People: PeopleStatistics(),
}
return result, nil
}
// PeopleStatistics pulls various metrics about users of the website.
func PeopleStatistics() People {
var result = People{
ByAgeRange: map[string]int64{},
ByGender: map[string]int64{},
ByOrientation: map[string]int64{},
}
type record struct {
MetricType string
MetricValue string
MetricCount int64
}
var records []record
res := models.DB.Raw(`
-- Users who opt in/out of explicit content
WITH subquery_explicit AS (
SELECT
SUM(CASE WHEN explicit IS TRUE THEN 1 ELSE 0 END) AS explicit_count,
SUM(CASE WHEN explicit IS NOT TRUE THEN 1 ELSE 0 END) AS non_explicit_count
FROM users
WHERE users.status = 'active'
AND users.certified IS TRUE
),
-- Users who share at least one explicit photo on public
subquery_explicit_photo AS (
SELECT
COUNT(*) AS user_count
FROM users
WHERE users.status = 'active'
AND users.certified IS TRUE
AND EXISTS (
SELECT 1
FROM photos
WHERE photos.user_id = users.id
AND photos.explicit IS TRUE
AND photos.visibility = 'public'
)
),
-- User counts by age
subquery_ages AS (
SELECT
CASE
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 0 AND 25 THEN '18-25'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 26 and 35 THEN '26-35'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 36 and 45 THEN '36-45'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 46 and 55 THEN '46-55'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 56 and 65 THEN '56-65'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 66 and 75 THEN '66-75'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 76 and 85 THEN '76-85'
ELSE '86+'
END AS age_range,
COUNT(*) AS user_count
FROM
users
GROUP BY
CASE
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 0 AND 25 THEN '18-25'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 26 and 35 THEN '26-35'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 36 and 45 THEN '36-45'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 46 and 55 THEN '46-55'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 56 and 65 THEN '56-65'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 66 and 75 THEN '66-75'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 76 and 85 THEN '76-85'
ELSE '86+'
END
),
-- User counts by gender
subquery_gender AS (
SELECT
profile_fields.value AS gender,
COUNT(*) AS user_count
FROM users
JOIN profile_fields ON profile_fields.user_id = users.id
WHERE users.status = 'active'
AND users.certified IS TRUE
AND profile_fields.name = 'gender'
GROUP BY profile_fields.value
),
-- User counts by orientation
subquery_orientation AS (
SELECT
profile_fields.value AS orientation,
COUNT(*) AS user_count
FROM users
JOIN profile_fields ON profile_fields.user_id = users.id
WHERE users.status = 'active'
AND users.certified IS TRUE
AND profile_fields.name = 'orientation'
GROUP BY profile_fields.value
)
SELECT
'ExplicitCount' AS metric_type,
'explicit' AS metric_value,
explicit_count AS metric_count
FROM subquery_explicit
UNION ALL
SELECT
'ExplicitPhotoCount' AS metric_type,
'count' AS metric_value,
user_count AS metric_count
FROM subquery_explicit_photo
UNION ALL
SELECT
'ExplicitCount' AS metric_type,
'non_explicit' AS metric_value,
non_explicit_count AS metric_count
FROM subquery_explicit
UNION ALL
SELECT
'AgeCounts' AS metric_type,
age_range AS metric_value,
user_count AS metric_count
FROM subquery_ages
UNION ALL
SELECT
'GenderCount' AS metric_type,
gender AS metric_value,
user_count AS metric_count
FROM subquery_gender
UNION ALL
SELECT
'OrientationCount' AS metric_type,
orientation AS metric_value,
user_count AS metric_count
FROM subquery_orientation
`).Scan(&records)
if res.Error != nil {
log.Error("PeopleStatistics: %s", res.Error)
return result
}
// Ingest the records.
for _, row := range records {
switch row.MetricType {
case "ExplicitCount":
result.Total += row.MetricCount
if row.MetricValue == "explicit" {
result.ExplicitOptIn = row.MetricCount
}
case "ExplicitPhotoCount":
result.ExplicitPhoto = row.MetricCount
case "AgeCounts":
if _, ok := result.ByAgeRange[row.MetricValue]; !ok {
result.ByAgeRange[row.MetricValue] = 0
}
result.ByAgeRange[row.MetricValue] += row.MetricCount
case "GenderCount":
if _, ok := result.ByGender[row.MetricValue]; !ok {
result.ByGender[row.MetricValue] = 0
}
result.ByGender[row.MetricValue] += row.MetricCount
case "OrientationCount":
if _, ok := result.ByOrientation[row.MetricValue]; !ok {
result.ByOrientation[row.MetricValue] = 0
}
result.ByOrientation[row.MetricValue] += row.MetricCount
}
}
return result
}
// PhotoStatistics gets info about photo usage on the website.
//
// Counts of Explicit vs. Non-Explicit photos.
func PhotoStatistics() Photo {
var result Photo
type record struct {
Explicit bool
C int64
}
var records []record
res := models.DB.Raw(`
SELECT
photos.explicit,
count(photos.id) AS c
FROM
photos
WHERE
photos.visibility = 'public'
GROUP BY photos.explicit
ORDER BY c DESC
`).Scan(&records)
if res.Error != nil {
log.Error("PhotoStatistics: %s", res.Error)
return result
}
for _, row := range records {
result.Total += row.C
if row.Explicit {
result.Explicit += row.C
} else {
result.NonExplicit += row.C
}
}
return result
}

View File

@ -19,30 +19,33 @@ func ExportModels(zw *zip.Writer, user *models.User) error {
// List of tables to export. Keep the ordering in sync with // List of tables to export. Keep the ordering in sync with
// the AutoMigrate() calls in ../models.go // the AutoMigrate() calls in ../models.go
var todo = []task{ var todo = []task{
{"User", ExportUserTable}, // Note: AdminGroup info is eager-loaded in User export
{"Block", ExportBlockTable},
{"CertificationPhoto", ExportCertificationPhotoTable},
{"ChangeLog", ExportChangeLogTable},
{"Comment", ExportCommentTable},
{"CommentPhoto", ExportCommentPhotoTable},
{"Feedback", ExportFeedbackTable},
{"ForumMembership", ExportForumMembershipTable},
{"Friend", ExportFriendTable},
{"Forum", ExportForumTable},
{"IPAddress", ExportIPAddressTable},
{"Like", ExportLikeTable},
{"Message", ExportMessageTable},
{"Notification", ExportNotificationTable},
{"ProfileField", ExportProfileFieldTable}, {"ProfileField", ExportProfileFieldTable},
{"Photo", ExportPhotoTable}, {"Photo", ExportPhotoTable},
{"PrivatePhoto", ExportPrivatePhotoTable},
{"CertificationPhoto", ExportCertificationPhotoTable},
{"Message", ExportMessageTable},
{"Friend", ExportFriendTable},
{"Block", ExportBlockTable},
{"Feedback", ExportFeedbackTable},
{"Forum", ExportForumTable},
{"Thread", ExportThreadTable},
{"Comment", ExportCommentTable},
{"Like", ExportLikeTable},
{"Notification", ExportNotificationTable},
{"Subscription", ExportSubscriptionTable},
{"CommentPhoto", ExportCommentPhotoTable},
// Note: Poll table is eager-loaded in Thread export // Note: Poll table is eager-loaded in Thread export
{"PollVote", ExportPollVoteTable}, {"PollVote", ExportPollVoteTable},
// Note: AdminGroup info is eager-loaded in User export {"PrivatePhoto", ExportPrivatePhotoTable},
{"PushNotification", ExportPushNotificationTable},
{"Subscription", ExportSubscriptionTable},
{"Thread", ExportThreadTable},
{"TwoFactor", ExportTwoFactorTable},
{"UsageStatistic", ExportUsageStatisticTable},
{"User", ExportUserTable},
{"UserLocation", ExportUserLocationTable}, {"UserLocation", ExportUserLocationTable},
{"UserNote", ExportUserNoteTable}, {"UserNote", ExportUserNoteTable},
{"ChangeLog", ExportChangeLogTable},
{"TwoFactor", ExportTwoFactorTable},
{"IPAddress", ExportIPAddressTable},
} }
for _, item := range todo { for _, item := range todo {
log.Info("Exporting data model: %s", item.Step) log.Info("Exporting data model: %s", item.Step)
@ -444,3 +447,48 @@ func ExportIPAddressTable(zw *zip.Writer, user *models.User) error {
return ZipJson(zw, "ip_addresses.json", items) return ZipJson(zw, "ip_addresses.json", items)
} }
func ExportForumMembershipTable(zw *zip.Writer, user *models.User) error {
var (
items = []*models.ForumMembership{}
query = models.DB.Model(&models.ForumMembership{}).Where(
"user_id = ?",
user.ID,
).Find(&items)
)
if query.Error != nil {
return query.Error
}
return ZipJson(zw, "forum_memberships.json", items)
}
func ExportPushNotificationTable(zw *zip.Writer, user *models.User) error {
var (
items = []*models.PushNotification{}
query = models.DB.Model(&models.PushNotification{}).Where(
"user_id = ?",
user.ID,
).Find(&items)
)
if query.Error != nil {
return query.Error
}
return ZipJson(zw, "push_notifications.json", items)
}
func ExportUsageStatisticTable(zw *zip.Writer, user *models.User) error {
var (
items = []*models.UsageStatistic{}
query = models.DB.Model(&models.UsageStatistic{}).Where(
"user_id = ?",
user.ID,
).Find(&items)
)
if query.Error != nil {
return query.Error
}
return ZipJson(zw, "usage_statistics.json", items)
}

View File

@ -31,9 +31,10 @@ func AutoMigrate() {
&Poll{}, // vacuum script cleans up orphaned polls &Poll{}, // vacuum script cleans up orphaned polls
&PrivatePhoto{}, // ✔ &PrivatePhoto{}, // ✔
&PushNotification{}, // ✔ &PushNotification{}, // ✔
&Subscription{}, // ✔
&Thread{}, // ✔ &Thread{}, // ✔
&TwoFactor{}, // ✔ &TwoFactor{}, // ✔
&Subscription{}, // ✔ &UsageStatistic{}, // ✔
&User{}, // ✔ &User{}, // ✔
&UserLocation{}, // ✔ &UserLocation{}, // ✔
&UserNote{}, // ✔ &UserNote{}, // ✔

View File

@ -0,0 +1,105 @@
package models
import "time"
/*
UsageStatistic holds basic analytics points for things like daily/monthly active user counts.
Generally, there will be one UserStatistic row for each combination of a UserID and Type for
each calendar day of the year. Type names may be like "dau" to log daily logins (Daily Active User),
or "chat" to log daily chat room users.
If a user logs in multiple times in the same day, their existing UsageStatistic for that day
is reused and the Counter is incremented. So if a user joins chat 3 times on the same day, there
will be a single row for that date for that user, but with a Counter of 3 in that case.
This makes it easier to query for aggregate reports on daily/monthly active users since each
row/event type combo only appears once per user per day.
*/
type UsageStatistic struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"uniqueIndex:idx_usage_statistics"`
Type string `gorm:"uniqueIndex:idx_usage_statistics"`
Date string `gorm:"uniqueIndex:idx_usage_statistics"` // unique days, yyyy-mm-dd format.
Counter uint64
CreatedAt time.Time `gorm:"index"` // full timestamps
UpdatedAt time.Time `gorm:"index"`
}
// Options for UsageStatistic Type values.
const (
UsageStatisticDailyVisit = "dau" // daily active user counter
UsageStatisticChatEntry = "chat" // daily chat room users
UsageStatisticForumUser = "forum" // daily forum users (when they open a thread)
UsageStatisticGalleryUser = "gallery" // daily Site Gallery user (when viewing the site gallery)
)
// LogDailyActiveUser will ping a UserStatistic for the current user to mark them present for the day.
func LogDailyActiveUser(user *User) error {
var (
date = time.Now().Format(time.DateOnly)
_, err = IncrementUsageStatistic(user, UsageStatisticDailyVisit, date)
)
return err
}
// LogDailyChatUser will ping a UserStatistic for the current user to mark them as having used the chat room today.
func LogDailyChatUser(user *User) error {
var (
date = time.Now().Format(time.DateOnly)
_, err = IncrementUsageStatistic(user, UsageStatisticChatEntry, date)
)
return err
}
// LogDailyForumUser will ping a UserStatistic for the current user to mark them as having used the forums today.
func LogDailyForumUser(user *User) error {
var (
date = time.Now().Format(time.DateOnly)
_, err = IncrementUsageStatistic(user, UsageStatisticForumUser, date)
)
return err
}
// LogDailyGalleryUser will ping a UserStatistic for the current user to mark them as having used the site gallery today.
func LogDailyGalleryUser(user *User) error {
var (
date = time.Now().Format(time.DateOnly)
_, err = IncrementUsageStatistic(user, UsageStatisticGalleryUser, date)
)
return err
}
// GetUsageStatistic looks up a user statistic.
func GetUsageStatistic(user *User, statType, date string) (*UsageStatistic, error) {
var (
result = &UsageStatistic{}
res = DB.Model(&UsageStatistic{}).Where(
"user_id = ? AND type = ? AND date = ?",
user.ID, statType, date,
).First(&result)
)
return result, res.Error
}
// IncrementUsageStatistic finds or creates a UserStatistic type and increments the counter.
func IncrementUsageStatistic(user *User, statType, date string) (*UsageStatistic, error) {
user.muStatistic.Lock()
defer user.muStatistic.Unlock()
// Is there an existing row?
stat, err := GetUsageStatistic(user, statType, date)
if err != nil {
stat = &UsageStatistic{
UserID: user.ID,
Type: statType,
Counter: 0,
Date: date,
}
}
// Update and save it.
stat.Counter++
err = DB.Save(stat).Error
return stat, err
}

View File

@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"sync"
"time" "time"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
@ -45,6 +46,9 @@ type User struct {
cachePhotoTypes map[PhotoVisibility]struct{} cachePhotoTypes map[PhotoVisibility]struct{}
cacheBlockedUserIDs []uint64 cacheBlockedUserIDs []uint64
cachePhotoIDs []uint64 cachePhotoIDs []uint64
// Feature mutexes.
muStatistic sync.Mutex
} }
type UserVisibility string type UserVisibility string
@ -226,6 +230,17 @@ func IsValidUsername(username string) error {
return nil return nil
} }
// PingLastLoginAt refreshes the user's "last logged in" time.
func (u *User) PingLastLoginAt() error {
// Also ping their daily active user statistic.
if err := LogDailyActiveUser(u); err != nil {
log.Error("PingLastLoginAt(%s): couldn't log daily active user statistic: %s", u.Username, err)
}
u.LastLoginAt = time.Now()
return u.Save()
}
// IsBanned returns if the user account is banned. // IsBanned returns if the user account is banned.
func (u *User) IsBanned() bool { func (u *User) IsBanned() bool {
return u.Status == UserStatusBanned return u.Status == UserStatusBanned

View File

@ -34,6 +34,7 @@ func New() http.Handler {
mux.HandleFunc("GET /sw.js", index.ServiceWorker()) mux.HandleFunc("GET /sw.js", index.ServiceWorker())
mux.HandleFunc("GET /about", index.StaticTemplate("about.html")()) mux.HandleFunc("GET /about", index.StaticTemplate("about.html")())
mux.HandleFunc("GET /features", index.StaticTemplate("features.html")()) mux.HandleFunc("GET /features", index.StaticTemplate("features.html")())
mux.HandleFunc("GET /insights", index.Demographics())
mux.HandleFunc("GET /faq", index.StaticTemplate("faq.html")()) mux.HandleFunc("GET /faq", index.StaticTemplate("faq.html")())
mux.HandleFunc("GET /tos", index.StaticTemplate("tos.html")()) mux.HandleFunc("GET /tos", index.StaticTemplate("tos.html")())
mux.HandleFunc("GET /privacy", index.StaticTemplate("privacy.html")()) mux.HandleFunc("GET /privacy", index.StaticTemplate("privacy.html")())

View File

@ -172,8 +172,7 @@ func LoginUser(w http.ResponseWriter, r *http.Request, u *models.User) error {
sess.Save(w) sess.Save(w)
// Ping the user's last login time. // Ping the user's last login time.
u.LastLoginAt = time.Now() return u.PingLastLoginAt()
return u.Save()
} }
// ImpersonateUser assumes the role of the user impersonated by an admin uid. // ImpersonateUser assumes the role of the user impersonated by an admin uid.

View File

@ -0,0 +1,233 @@
{{define "title"}}A peek inside the {{.Title}} website{{end}}
{{define "content"}}
<div class="block">
<section class="hero is-light is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">A peek inside the {{PrettyTitle}} website</h1>
<h2 class="subtitle">Some statistics &amp; demographics of our members &amp; content</h2>
</div>
</div>
</section>
</div>
<div class="block p-4">
<div class="content">
<p>
This page provides some insights into the distribution of content and member demographics
in the {{PrettyTitle}} community.
</p>
<p>
If you are a prospective new member and are curious about whether this isn't actually "just another porn site,"
hopefully this page will help answer some of those questions for you.
</p>
<p>
We have found that {{PrettyTitle}} actually fills a much needed niche <em>in between</em> the two
opposite poles of the "strict naturist website" and the "hyper sexual porn sites" that exist elsewhere
online. Many of our members have come here from other major nudist websites, and we have so far maintained a
nice balance in content: <strong>most</strong> of what people share here are "normal nudes" that
don't contain any sexual undertones at all!
</p>
<p>
On our <a href="/features#webcam-chat-room">webcam chat room</a>, though we permit people to be sexual
on camera when they want to be, we typically maintain a balance where <em>most</em> of the webcams are
"blue" (or non-sexual in nature). Members are asked to mark their webcams as 'explicit' (red) when they are
being horny, so you may have informed consent as to whether you want to open their red cam and watch.
</p>
<p>
The insights on this page should hopefully back up this story with some hard numbers so you may get a preview
of what you may expect to find on this website should you decide to join us here.
</p>
<p>
{{PrettyTitle}} is open to <strong>all</strong> nudists &amp; exhibitionists and we'd love to have you join our community!
</p>
<p>
<strong><em>Last Updated:</em></strong> these website statistics were last collected
on {{.Demographic.LastUpdated.Format "Jan _2, 2006 @ 15:04:05 MST"}}
<!-- Admin: force refresh -->
{{if .CurrentUser.IsAdmin}}
<a href="{{.Request.URL.Path}}?refresh=true" class="has-text-danger ml-2">
<i class="fa fa-peace"></i> Refresh now
</a>
{{end}}
</p>
</div>
<!--
Photo Gallery Statistics
-->
<hr>
<div class="content">
<h2>
<i class="fa fa-image mr-2"></i>
Photo Gallery
</h2>
<p>
Our members have shared <strong>{{FormatNumberCommas .Demographic.Photo.Total}}</strong> photos on
"public" for the whole {{PrettyTitle}} community to see. The majority of these photos tend to be "normal nudes"
and are non-sexual in nature (for example, not featuring so much as an erection or sexually enticing pose).
</p>
</div>
<div class="columns">
<div class="column is-2">
<strong>Non-Explicit</strong>
<small class="has-text-grey">({{FormatNumberCommas .Demographic.Photo.NonExplicit}})</small>
</div>
<div class="column pt-4">
<progress class="progress is-link has-background-dark"
value="{{.Demographic.Photo.PercentNonExplicit}}"
max="100"
title="{{.Demographic.Photo.PercentNonExplicit}}%">
{{.Demographic.Photo.PercentNonExplicit}}%
</progress>
</div>
<div class="column is-narrow">
{{.Demographic.Photo.PercentNonExplicit}}%
</div>
</div>
<div class="columns">
<div class="column is-2">
<strong>Explicit</strong>
<small class="has-text-grey">({{FormatNumberCommas .Demographic.Photo.Explicit}})</small>
</div>
<div class="column pt-4">
<progress class="progress is-danger has-background-dark"
value="{{.Demographic.Photo.PercentExplicit}}"
max="100"
title="{{.Demographic.Photo.PercentExplicit}}%">
{{.Demographic.Photo.PercentExplicit}}%
</progress>
</div>
<div class="column is-narrow">
{{.Demographic.Photo.PercentExplicit}}%
</div>
</div>
<div class="content">
<p>
Remember: this website is "nudist friendly" by default, and you must <strong>opt-in</strong> if you want to
see the 'explicit' content on this website.
</p>
<p>
So far: <strong>{{FormatNumberCommas .Demographic.People.ExplicitOptIn}}</strong> of our members
({{.Demographic.People.PercentExplicit}}%) opt-in to see explicit content, and
<strong>{{FormatNumberCommas .Demographic.People.ExplicitPhoto}}</strong>
({{.Demographic.People.PercentExplicitPhoto}}%) of them have actually shared at least one 'explicit'
photo on their public gallery.
</p>
</div>
<!--
People statistics
-->
<hr>
<div class="content">
<h2>
<i class="fa fa-people-group mr-2"></i>
People
</h2>
<p>
We currently have <strong>{{FormatNumberCommas .Demographic.People.Total}}</strong> members who have
verified their <a href="/faq#certification-faqs">certification photo</a> with the site admin.
</p>
<p>
Below are some high-level demographics of who you may expect to find on this website. As a note on
genders: the majority of our members tend to be men, but we do have a handful of women here too.
{{PrettyTitle}} grows <em>exclusively</em> by word of mouth: if you know any women who might like
the vibe of this place, please do recommend that they check it out!
</p>
</div>
<div class="columns">
<div class="column">
<h4 class="is-size-5 mb-4">By Age Range</h4>
{{range .Demographic.People.IterAgeRanges}}
<div class="columns">
<div class="column is-3">
<strong>{{or .Label "(No answer)"}}</strong>
<small class="has-text-grey">({{FormatNumberCommas .Count}})</small>
</div>
<div class="column pt-4">
<progress class="progress is-success has-background-dark"
value="{{.Percent}}"
max="100"
title="{{.Percent}}%">
{{.Percent}}%
</progress>
</div>
<div class="column is-narrow">
{{.Percent}}%
</div>
</div>
{{end}}
</div>
<div class="column">
<h4 class="is-size-5 mb-4">By Gender</h4>
{{range .Demographic.People.IterGenders}}
<div class="columns">
<div class="column is-3">
<strong>{{or .Label "No answer"}}</strong>
<small class="has-text-grey">({{FormatNumberCommas .Count}})</small>
</div>
<div class="column pt-4">
<progress class="progress is-link has-background-dark"
value="{{.Percent}}"
max="100"
title="{{.Percent}}%">
{{.Percent}}%
</progress>
</div>
<div class="column is-narrow">
{{.Percent}}%
</div>
</div>
{{end}}
</div>
</div>
<div class="columns">
<div class="column is-half">
<h4 class="is-size-5 mb-4">By Orientation</h4>
{{range .Demographic.People.IterOrientations}}
<div class="columns">
<div class="column is-3">
<strong>{{or .Label "(No answer)"}}</strong>
<small class="has-text-grey">({{FormatNumberCommas .Count}})</small>
</div>
<div class="column pt-4">
<progress class="progress is-danger has-background-dark"
value="{{.Percent}}"
max="100"
title="{{.Percent}}%">
{{.Percent}}%
</progress>
</div>
<div class="column is-narrow">
{{.Percent}}%
</div>
</div>
{{end}}
</div>
</div>
</div>
{{end}}

View File

@ -41,29 +41,34 @@
content is strictly opt-in and the default is to hide any explicit photos or forums from your view. content is strictly opt-in and the default is to hide any explicit photos or forums from your view.
</p> </p>
<!-- Show a peek at website demographics -->
{{if .Demographic.Computed}}
<h4><em>A peek inside the site:</em></h4> <h4><em>A peek inside the site:</em></h4>
<p> <p>
{{PrettyTitle}} has been found to fill a much needed niche in between the "strict naturist websites" {{PrettyTitle}} has been found to fill a much needed niche in between the "strict naturist websites"
and the "hyper sexual porn sites" out there. As of <strong>July 31, 2024</strong> here is a brief peek inside the and the "hyper sexual porn sites" out there. As of <strong>{{.Demographic.LastUpdated.Format "January _2, 2006"}}</strong> here is a brief
website to see what the balance of content is like in our community. <a href="/insights">peek inside the website</a> to see what the balance of content is like in our community.
</p> </p>
<ul> <ul>
<li> <li>
Photo Gallery: only 24% of our photos are 'explicit' or sexual in nature (6,750 out of 27,331). Photo Gallery: only {{.Demographic.Photo.PercentExplicit}}% of our photos are 'explicit' or sexual in nature ({{FormatNumberCommas .Demographic.Photo.Explicit}} out of {{FormatNumberCommas .Demographic.Photo.Total}}).
It is strictly opt-in if you want to see that stuff - it's hidden by default! It is strictly opt-in if you want to see that stuff - it's hidden by default!
</li> </li>
<li>Nudists vs. Exhibitionists: 3,209 (71%, out of 4,462) of members have opted-in to see explicit content on the site. <li>Nudists vs. Exhibitionists: {{FormatNumberCommas .Demographic.People.ExplicitOptIn}} ({{.Demographic.People.PercentExplicit}}%, out of {{FormatNumberCommas .Demographic.People.Total}}) of members have opted-in to see explicit content on the site.
Only 45% of members (2,022) have shared at least one 'explicit' photo on their gallery.</li> Only {{.Demographic.People.PercentExplicitPhoto}}% of members ({{FormatNumberCommas .Demographic.People.ExplicitPhoto}}) have shared at least one 'explicit' photo on their gallery.</li>
</ul> </ul>
<p> <p>
<strong>
<i class="fa fa-circle-arrow-right mr-1"></i> See more:
</strong>
<small> <small>
(Coming soon: a 'live statistics' page which will give up-to-date information and pretty graphs &amp; charts; <a href="/insights">Click here to see detailed insights</a> about the people and content in our community -- updated regularly!
in the mean time these were manually gathered).
</small> </small>
</p> </p>
{{end}}
</div> </div>