From 2f31d678d01b5aa9df59913225ffae1ef9b8f6cc Mon Sep 17 00:00:00 2001
From: Noah Petherbridge
Date: Wed, 11 Sep 2024 19:28:52 -0700
Subject: [PATCH] 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.
---
pkg/config/config.go | 3 +
pkg/controller/chat/chat.go | 7 +
pkg/controller/forum/thread.go | 7 +
pkg/controller/index/demographics.go | 57 ++++++
pkg/controller/index/index.go | 13 +-
pkg/controller/photo/site_gallery.go | 8 +
pkg/middleware/authentication.go | 3 +-
pkg/models/deletion/delete_user.go | 11 +
pkg/models/demographic/demographic.go | 195 ++++++++++++++++++
pkg/models/demographic/queries.go | 284 ++++++++++++++++++++++++++
pkg/models/exporting/models.go | 84 ++++++--
pkg/models/models.go | 3 +-
pkg/models/usage_statistic.go | 105 ++++++++++
pkg/models/user.go | 15 ++
pkg/router/router.go | 1 +
pkg/session/session.go | 3 +-
web/templates/demographics.html | 233 +++++++++++++++++++++
web/templates/index.html | 19 +-
18 files changed, 1020 insertions(+), 31 deletions(-)
create mode 100644 pkg/controller/index/demographics.go
create mode 100644 pkg/models/demographic/demographic.go
create mode 100644 pkg/models/demographic/queries.go
create mode 100644 pkg/models/usage_statistic.go
create mode 100644 web/templates/demographics.html
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 68e94b5..b5d5b27 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -83,6 +83,9 @@ const (
// Chat room status refresh interval.
ChatStatusRefreshInterval = 30 * time.Second
+
+ // Cache TTL for the demographics page.
+ DemographicsCacheTTL = time.Hour
)
var (
diff --git a/pkg/controller/chat/chat.go b/pkg/controller/chat/chat.go
index 8afa506..8347aaf 100644
--- a/pkg/controller/chat/chat.go
+++ b/pkg/controller/chat/chat.go
@@ -154,6 +154,13 @@ func Landing() http.HandlerFunc {
// of time where they can exist in chat but change their name on the site.
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.
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss)
return
diff --git a/pkg/controller/forum/thread.go b/pkg/controller/forum/thread.go
index bb39df7..79f19b0 100644
--- a/pkg/controller/forum/thread.go
+++ b/pkg/controller/forum/thread.go
@@ -97,6 +97,13 @@ func Thread() http.HandlerFunc {
// Is the current user subscribed to notifications on this thread?
_, 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{}{
"Forum": forum,
"Thread": thread,
diff --git a/pkg/controller/index/demographics.go b/pkg/controller/index/demographics.go
new file mode 100644
index 0000000..34dceb1
--- /dev/null
+++ b/pkg/controller/index/demographics.go
@@ -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
+ }
+ })
+}
diff --git a/pkg/controller/index/index.go b/pkg/controller/index/index.go
index 648eece..142c68e 100644
--- a/pkg/controller/index/index.go
+++ b/pkg/controller/index/index.go
@@ -5,6 +5,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
+ "code.nonshy.com/nonshy/website/pkg/models/demographic"
"code.nonshy.com/nonshy/website/pkg/templates"
)
@@ -18,7 +19,17 @@ func Create() http.HandlerFunc {
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)
return
}
diff --git a/pkg/controller/photo/site_gallery.go b/pkg/controller/photo/site_gallery.go
index d5a7b3f..13171fc 100644
--- a/pkg/controller/photo/site_gallery.go
+++ b/pkg/controller/photo/site_gallery.go
@@ -4,6 +4,7 @@ import (
"net/http"
"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/session"
"code.nonshy.com/nonshy/website/pkg/templates"
@@ -121,6 +122,13 @@ func SiteGallery() http.HandlerFunc {
likeMap := models.MapLikes(currentUser, "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{}{
"IsSiteGallery": true,
"Photos": photos,
diff --git a/pkg/middleware/authentication.go b/pkg/middleware/authentication.go
index 6c33f5f..6a054c9 100644
--- a/pkg/middleware/authentication.go
+++ b/pkg/middleware/authentication.go
@@ -48,9 +48,8 @@ func LoginRequired(handler http.Handler) http.Handler {
// Ping LastLoginAt for long lived sessions, but not if impersonated.
var pingLastLoginAt bool
if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown && !session.Impersonated(r) {
- user.LastLoginAt = time.Now()
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)
}
}
diff --git a/pkg/models/deletion/delete_user.go b/pkg/models/deletion/delete_user.go
index 3cb15f6..c41e4cf 100644
--- a/pkg/models/deletion/delete_user.go
+++ b/pkg/models/deletion/delete_user.go
@@ -59,6 +59,7 @@ func DeleteUser(user *models.User) error {
{"IP Addresses", DeleteIPAddresses},
{"Push Notifications", DeletePushNotifications},
{"Forum Memberships", DeleteForumMemberships},
+ {"Usage Statistics", DeleteUsageStatistics},
}
for _, item := range todo {
if err := item.Fn(user.ID); err != nil {
@@ -406,3 +407,13 @@ func DeleteForumMemberships(userID uint64) error {
).Delete(&models.ForumMembership{})
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
+}
diff --git a/pkg/models/demographic/demographic.go b/pkg/models/demographic/demographic.go
new file mode 100644
index 0000000..fea99b4
--- /dev/null
+++ b/pkg/models/demographic/demographic.go
@@ -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
+}
diff --git a/pkg/models/demographic/queries.go b/pkg/models/demographic/queries.go
new file mode 100644
index 0000000..df0a52d
--- /dev/null
+++ b/pkg/models/demographic/queries.go
@@ -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
+}
diff --git a/pkg/models/exporting/models.go b/pkg/models/exporting/models.go
index 15fd80c..53b89a2 100644
--- a/pkg/models/exporting/models.go
+++ b/pkg/models/exporting/models.go
@@ -19,30 +19,33 @@ func ExportModels(zw *zip.Writer, user *models.User) error {
// List of tables to export. Keep the ordering in sync with
// the AutoMigrate() calls in ../models.go
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},
{"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
{"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},
{"UserNote", ExportUserNoteTable},
- {"ChangeLog", ExportChangeLogTable},
- {"TwoFactor", ExportTwoFactorTable},
- {"IPAddress", ExportIPAddressTable},
}
for _, item := range todo {
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)
}
+
+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)
+}
diff --git a/pkg/models/models.go b/pkg/models/models.go
index 679558a..f2664b6 100644
--- a/pkg/models/models.go
+++ b/pkg/models/models.go
@@ -31,9 +31,10 @@ func AutoMigrate() {
&Poll{}, // vacuum script cleans up orphaned polls
&PrivatePhoto{}, // ✔
&PushNotification{}, // ✔
+ &Subscription{}, // ✔
&Thread{}, // ✔
&TwoFactor{}, // ✔
- &Subscription{}, // ✔
+ &UsageStatistic{}, // ✔
&User{}, // ✔
&UserLocation{}, // ✔
&UserNote{}, // ✔
diff --git a/pkg/models/usage_statistic.go b/pkg/models/usage_statistic.go
new file mode 100644
index 0000000..d75eef8
--- /dev/null
+++ b/pkg/models/usage_statistic.go
@@ -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
+}
diff --git a/pkg/models/user.go b/pkg/models/user.go
index 5fc3027..6d49315 100644
--- a/pkg/models/user.go
+++ b/pkg/models/user.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"strings"
+ "sync"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
@@ -45,6 +46,9 @@ type User struct {
cachePhotoTypes map[PhotoVisibility]struct{}
cacheBlockedUserIDs []uint64
cachePhotoIDs []uint64
+
+ // Feature mutexes.
+ muStatistic sync.Mutex
}
type UserVisibility string
@@ -226,6 +230,17 @@ func IsValidUsername(username string) error {
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.
func (u *User) IsBanned() bool {
return u.Status == UserStatusBanned
diff --git a/pkg/router/router.go b/pkg/router/router.go
index 1bad99b..7561b10 100644
--- a/pkg/router/router.go
+++ b/pkg/router/router.go
@@ -34,6 +34,7 @@ func New() http.Handler {
mux.HandleFunc("GET /sw.js", index.ServiceWorker())
mux.HandleFunc("GET /about", index.StaticTemplate("about.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 /tos", index.StaticTemplate("tos.html")())
mux.HandleFunc("GET /privacy", index.StaticTemplate("privacy.html")())
diff --git a/pkg/session/session.go b/pkg/session/session.go
index 28953ac..c3affa1 100644
--- a/pkg/session/session.go
+++ b/pkg/session/session.go
@@ -172,8 +172,7 @@ func LoginUser(w http.ResponseWriter, r *http.Request, u *models.User) error {
sess.Save(w)
// Ping the user's last login time.
- u.LastLoginAt = time.Now()
- return u.Save()
+ return u.PingLastLoginAt()
}
// ImpersonateUser assumes the role of the user impersonated by an admin uid.
diff --git a/web/templates/demographics.html b/web/templates/demographics.html
new file mode 100644
index 0000000..b9c890a
--- /dev/null
+++ b/web/templates/demographics.html
@@ -0,0 +1,233 @@
+{{define "title"}}A peek inside the {{.Title}} website{{end}}
+{{define "content"}}
+
+
+
+
+
A peek inside the {{PrettyTitle}} website
+ Some statistics & demographics of our members & content
+
+
+
+
+
+
+
+
+ This page provides some insights into the distribution of content and member demographics
+ in the {{PrettyTitle}} community.
+
+
+
+ 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.
+
+
+
+ We have found that {{PrettyTitle}} actually fills a much needed niche in between 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: most of what people share here are "normal nudes" that
+ don't contain any sexual undertones at all!
+
+
+
+ On our webcam chat room, though we permit people to be sexual
+ on camera when they want to be, we typically maintain a balance where most 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.
+
+
+
+ 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.
+
+
+
+ {{PrettyTitle}} is open to all nudists & exhibitionists and we'd love to have you join our community!
+
+
+
+ Last Updated: these website statistics were last collected
+ on {{.Demographic.LastUpdated.Format "Jan _2, 2006 @ 15:04:05 MST"}}
+
+
+ {{if .CurrentUser.IsAdmin}}
+
+ Refresh now
+
+ {{end}}
+
+
+
+
+
+
+
+
+
+
+ Photo Gallery
+
+
+
+ Our members have shared {{FormatNumberCommas .Demographic.Photo.Total}} 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).
+
+
+
+
+
+ Non-Explicit
+ ({{FormatNumberCommas .Demographic.Photo.NonExplicit}})
+
+
+
+
+
+ {{.Demographic.Photo.PercentNonExplicit}}%
+
+
+
+
+ Explicit
+ ({{FormatNumberCommas .Demographic.Photo.Explicit}})
+
+
+
+
+
+ {{.Demographic.Photo.PercentExplicit}}%
+
+
+
+
+
+ Remember: this website is "nudist friendly" by default, and you must opt-in if you want to
+ see the 'explicit' content on this website.
+
+
+
+ So far: {{FormatNumberCommas .Demographic.People.ExplicitOptIn}} of our members
+ ({{.Demographic.People.PercentExplicit}}%) opt-in to see explicit content, and
+ {{FormatNumberCommas .Demographic.People.ExplicitPhoto}}
+ ({{.Demographic.People.PercentExplicitPhoto}}%) of them have actually shared at least one 'explicit'
+ photo on their public gallery.
+
+
+
+
+
+
+
+
+
+
+ People
+
+
+
+ We currently have {{FormatNumberCommas .Demographic.People.Total}} members who have
+ verified their certification photo with the site admin.
+
+
+
+ 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 exclusively 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!
+
+
+
+
+
+
By Age Range
+
+ {{range .Demographic.People.IterAgeRanges}}
+
+
+ {{or .Label "(No answer)"}}
+ ({{FormatNumberCommas .Count}})
+
+
+
+ {{.Percent}}%
+
+
+ {{end}}
+
+
+
+
By Gender
+
+ {{range .Demographic.People.IterGenders}}
+
+
+ {{or .Label "No answer"}}
+ ({{FormatNumberCommas .Count}})
+
+
+
+ {{.Percent}}%
+
+
+ {{end}}
+
+
+
+
+
By Orientation
+
+ {{range .Demographic.People.IterOrientations}}
+
+
+ {{or .Label "(No answer)"}}
+ ({{FormatNumberCommas .Count}})
+
+
+
+ {{.Percent}}%
+
+
+ {{end}}
+
+
+
+
+{{end}}
diff --git a/web/templates/index.html b/web/templates/index.html
index 84d05a0..ca96427 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -41,29 +41,34 @@
content is strictly opt-in and the default is to hide any explicit photos or forums from your view.
+
+ {{if .Demographic.Computed}}
A peek inside the site:
{{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 July 31, 2024 here is a brief peek inside the
- website to see what the balance of content is like in our community.
+ and the "hyper sexual porn sites" out there. As of {{.Demographic.LastUpdated.Format "January _2, 2006"}} here is a brief
+ peek inside the website to see what the balance of content is like in our community.
-
- 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!
- - Nudists vs. Exhibitionists: 3,209 (71%, out of 4,462) 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.
+ - 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 {{.Demographic.People.PercentExplicitPhoto}}% of members ({{FormatNumberCommas .Demographic.People.ExplicitPhoto}}) have shared at least one 'explicit' photo on their gallery.
+
+ See more:
+
- (Coming soon: a 'live statistics' page which will give up-to-date information and pretty graphs & charts;
- in the mean time these were manually gathered).
+ Click here to see detailed insights about the people and content in our community -- updated regularly!
+ {{end}}