Compare commits

..

No commits in common. "main" and "user-forums" have entirely different histories.

145 changed files with 1139 additions and 7072 deletions

104
README.md
View File

@ -20,6 +20,20 @@ The website can also run out of a local SQLite database which is convenient
for local development. The production server runs on PostgreSQL and the
web app is primarily designed for that.
### PostGIS Extension for PostgreSQL
For the "Who's Nearby" feature to work you will need a PostgreSQL
database with the PostGIS geospatial extension installed. Usually
it might be a matter of `dnf install postgis` and activating the
extension on your nonshy database as your superuser (postgres):
```psql
create extension postgis;
```
If you get errors like "Type geography not found" from Postgres when
running distance based searches, this is the likely culprit.
## Building the App
This app is written in Go: [go.dev](https://go.dev). You can probably
@ -47,96 +61,6 @@ a database.
For simple local development, just set `"UseSQLite": true` and the
app will run with a SQLite database.
### Postgres is Highly Recommended
This website is intended to run under PostgreSQL and some of its
features leverage Postgres specific extensions. For quick local
development, SQLite will work fine but some website features will
be disabled and error messages given. These include:
* Location features such as "Who's Nearby" (PostGIS extension)
* "Newest" tab on the forums: to deduplicate comments by most recent
thread depends on Postgres, SQLite will always show all latest
comments without deduplication.
### PostGIS Extension for PostgreSQL
For the "Who's Nearby" feature to work you will need a PostgreSQL
database with the PostGIS geospatial extension installed. Usually
it might be a matter of `dnf install postgis` and activating the
extension on your nonshy database as your superuser (postgres):
```psql
create extension postgis;
```
If you get errors like "Type geography not found" from Postgres when
running distance based searches, this is the likely culprit.
### Signed Photo URLs (NGINX)
The website supports "signed photo" URLs that can help protect the direct
links to user photos (their /static/photos/*.jpg paths) to ensure only
logged-in and authorized users are able to access those links.
This feature is not enabled (enforcing) by default, as it relies on
cooperation with the NGINX reverse proxy server
(module ngx_http_auth_request).
In your NGINX config, set your /static/ path to leverage NGINX auth_request
like so:
```nginx
server {
# your boilerplate server info (SSL, etc.) - not relevant to this example.
listen 80 default_server;
listen [::]:80 default_server;
# Relevant: setting the /static/ URL on NGINX to be an alias to your local
# nonshy static folder on disk. In this example, the git clone for the
# website was at /home/www-user/git/nonshy/website, so that ./web/static/
# is the local path where static files (e.g., photos) are uploaded.
location /static/ {
# Important: auth_request tells NGINX to do subrequest authentication
# on requests into the /static/ URI of your website.
auth_request /static-auth;
# standard NGINX alias commands.
alias /home/www-user/git/nonshy/website/web/static/;
autoindex off;
}
# Configure the internal subrequest auth path.
# Note: the path "/static-auth" can be anything you want.
location = /static-auth {
internal; # this is an internal route for NGINX only, not public
# Proxy to the /v1/auth/static URL on the web app.
# This line assumes the website runs on localhost:8080.
proxy_pass http://localhost:8080/v1/auth/static;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
# Important: the X-Original-URI header tells the web app what the
# original path (e.g. /static/photos/*) was, so the web app knows
# which sub-URL to enforce authentication on.
proxy_set_header X-Original-URI $request_uri;
}
}
```
When your NGINX config is set up like the above, you can edit the
settings.json to mark SignedPhoto/Enabled=true, and restart the
website. Be sure to test it!
On a photo gallery view, all image URLs under /static/photos/ should
come with a ?jwt= parameter, and the image should load for the current
user. The JWT token is valid for 30 seconds after which the direct link
to the image should expire and give a 403 Forbidden response.
When this feature is NOT enabled/not enforcing: the jwt= parameter is
still generated on photo URLs but is not enforced by the web app.
## Usage
The `nonshy` binary has sub-commands to either run the web server

View File

@ -261,21 +261,6 @@ func main() {
return err
}
return nil
},
},
{
Name: "photo-counts",
Usage: "repopulate cached Likes and Comment counts on photos",
Action: func(c *cli.Context) error {
initdb(c)
log.Info("Running BackfillPhotoCounts()")
err := backfill.BackfillPhotoCounts()
if err != nil {
return err
}
return nil
},
},

View File

@ -37,10 +37,9 @@ func MaybeDisconnectUser(user *models.User) (bool, error) {
},
{
If: user.IsShy(),
Message: because + "you had updated your nonshy profile to become too private, and now are considered to have a 'Shy Account.'<br><br>" +
"You may <strong>refresh</strong> the page to log back into chat as a Shy Account, where your ability to use webcams and share photos " +
"will be restricted. To regain full access to the chat room, please edit your profile settings to make sure that at least one 'public' " +
"photo is viewable to other members of the website.<br><br>" +
Message: because + "you had updated your nonshy profile to become too private.<br><br>" +
"You may join the chat room after you have made your profile and (at least some) pictures " +
"viewable on 'public' so that you won't appear to be a blank, faceless profile to others on the chat room.<br><br>" +
"Please see the <a href=\"https://www.nonshy.com/faq#shy-faqs\">Shy Account FAQ</a> for more information.",
},
{

View File

@ -1,37 +0,0 @@
package config
import "strings"
// Admin Labels.
const (
// Admin Labels for Photos
AdminLabelPhotoNonExplicit = "non-explicit"
AdminLabelPhotoForceExplicit = "force-explicit"
)
var (
AdminLabelPhotoOptions = []ChecklistOption{
{
Value: AdminLabelPhotoNonExplicit,
Label: "This is not an Explicit photo",
Help: "Hide the prompt 'Should this photo be marked as explicit?' as this photo does not NEED to be Explicit. " +
"Note: the owner of this photo MAY still mark it explicit if they want to.",
},
{
Value: AdminLabelPhotoForceExplicit,
Label: "Force this photo to be marked as Explicit",
Help: "Enabling this option will force the Explicit tag to stay on, and not allow the user to remove it.",
},
}
)
// HasAdminLabel checks if a comma-separated set of admin labels contains the label.
func HasAdminLabel(needle string, haystack string) bool {
labels := strings.Split(haystack, ",")
for _, label := range labels {
if strings.TrimSpace(label) == needle {
return true
}
}
return false
}

View File

@ -10,11 +10,11 @@ import (
// returned by the scope list function.
func TestAdminScopesCount(t *testing.T) {
var scopes = config.ListAdminScopes()
if len(scopes) != config.QuantityAdminScopes || len(scopes) != len(config.AdminScopeDescriptions)-1 {
if len(scopes) != config.QuantityAdminScopes || len(scopes) != len(config.AdminScopeDescriptions) {
t.Errorf(
"The list of scopes returned by ListAdminScopes doesn't match the expected count. "+
"Expected %d (with %d descriptions), got %d",
config.QuantityAdminScopes, len(config.AdminScopeDescriptions),
"Expected %d, got %d",
config.QuantityAdminScopes,
len(scopes),
)
}

View File

@ -25,10 +25,6 @@ const (
PhotoDiskPath = "./web/static/photos"
)
// PhotoURLRegexp describes an image path under "/static/photos" that can be parsed from Markdown or HTML input.
// It is used by e.g. the ReSignURLs function - if you move image URLs to a CDN this may need updating.
var PhotoURLRegexp = regexp.MustCompile(`(?:['"])(/static/photos/[^'"\s?]+(?:\?[^'"\s]*)?)(?:['"]|[^'"\s]*)`)
// Security
const (
BcryptCost = 14
@ -42,11 +38,6 @@ const (
TwoFactorBackupCodeCount = 12
TwoFactorBackupCodeLength = 8 // characters a-z0-9
// Signed URLs for static photo authentication.
SignedPhotoJWTExpires = 30 * time.Second // Regular, per-user, short window
SignedPublicAvatarJWTExpires = 7 * 24 * time.Hour // Widely public, e.g. chat room
SignedPublicAvatarUsername = "@" // JWT 'username' for widely public JWT
)
// Authentication
@ -60,11 +51,6 @@ const (
ChangeEmailRedisKey = "change-email/%s"
SignupTokenExpires = 24 * time.Hour // used for all tokens so far
// How to rate limit same types of emails being delivered, e.g.
// signups, cert approvals (double post), etc.
EmailDebounceDefault = 24 * time.Hour // default debounce per type of email
EmailDebounceResetPassword = 4 * time.Hour // "forgot password" emails debounce
// Rate limits
RateLimitRedisKey = "rate-limit/%s/%s" // namespace, id
LoginRateLimitWindow = 1 * time.Hour
@ -92,9 +78,6 @@ const (
// Chat room status refresh interval.
ChatStatusRefreshInterval = 30 * time.Second
// Cache TTL for the demographics page.
DemographicsCacheTTL = time.Hour
)
var (
@ -130,12 +113,6 @@ const (
// pictures can be posted per day.
SiteGalleryRateLimitMax = 5
SiteGalleryRateLimitInterval = 24 * time.Hour
// Only ++ the Views count per user per photo within a small
// window of time - if a user keeps reloading the same photo
// rapidly it does not increment the view counter more.
PhotoViewDebounceRedisKey = "debounce-view/user=%d/photoid=%d"
PhotoViewDebounceCooldown = 1 * time.Hour
)
// Forum settings

View File

@ -79,61 +79,10 @@ var (
"dm_privacy",
"blur_explicit",
"site_gallery_default", // default view on site gallery (friends-only or all certified?)
"chat_moderation_rules",
}
// Website theme color hue choices.
WebsiteThemeHueChoices = []OptGroup{
{
Header: "Custom Themes",
Options: []Option{
{
Label: "Default (no added color; classic nonshy theme)",
Value: "",
},
{
Label: "nonshy blue & pink",
Value: "blue-pink",
},
},
},
{
Header: "Just a Splash of Color",
Options: []Option{
{
Label: "Burnt red",
Value: "red",
},
{
Label: "Harvest orange",
Value: "orange",
},
{
Label: "Golden yellow",
Value: "yellow",
},
{
Label: "Leafy green",
Value: "green",
},
{
Label: "Cool blue",
Value: "blue",
},
{
Label: "Pretty in pink",
Value: "pink",
},
{
Label: "Royal purple",
Value: "purple",
},
},
},
}
// Choices for the Contact Us subject
ContactUsChoices = []OptGroup{
ContactUsChoices = []ContactUs{
{
Header: "Website Feedback",
Options: []Option{
@ -165,83 +114,15 @@ var (
"Anything Goes",
}
// Forum Poll expiration options.
PollExpires = []Option{
{
Label: "Never",
Value: "0",
},
{
Label: "1 Day",
Value: "1",
},
{
Label: "2 Days",
Value: "2",
},
{
Label: "3 Days",
Value: "3",
},
{
Label: "4 Days",
Value: "4",
},
{
Label: "5 Days",
Value: "5",
},
{
Label: "6 Days",
Value: "6",
},
{
Label: "7 Days",
Value: "7",
},
{
Label: "2 Weeks",
Value: "14",
},
{
Label: "1 Month (30 days)",
Value: "30",
},
}
// Keywords that appear in a DM that make it likely spam.
DirectMessageSpamKeywords = []*regexp.Regexp{
regexp.MustCompile(`\b(telegram|whats\s*app|signal|kik|session)\b`),
regexp.MustCompile(`https?://(t.me|join.skype.com|zoom.us|whereby.com|meet.jit.si|wa.me)`),
}
// Chat Moderation Rules.
ChatModerationRules = []ChecklistOption{
{
Value: "redcam",
Label: "Red camera",
Help: "The user's camera is forced to 'explicit' when they are broadcasting.",
},
{
Value: "nobroadcast",
Label: "No broadcast",
Help: "The user can not broadcast their webcam, but may still watch other peoples' webcams.",
},
{
Value: "novideo",
Label: "No webcam privileges ('Shy Accounts')",
Help: "The user can not broadcast or watch any webcam. Note: this option supercedes all other video-related rules.",
},
{
Value: "noimage",
Label: "No image sharing privileges ('Shy Accounts')",
Help: "The user can not share or see any image shared on chat.",
},
}
)
// OptGroup choices for the subject drop-down.
type OptGroup struct {
// ContactUs choices for the subject drop-down.
type ContactUs struct {
Header string
Options []Option
}
@ -252,13 +133,6 @@ type Option struct {
Label string
}
// ChecklistOption for checkbox-lists.
type ChecklistOption struct {
Value string
Label string
Help string
}
// NotificationOptout field values (stored in user ProfileField table)
const (
NotificationOptOutFriendPhotos = "notif_optout_friends_photos"

View File

@ -12,7 +12,6 @@ var (
PageSizeMemberSearch = 60
PageSizeFriends = 12
PageSizeBlockList = 12
PageSizeMuteList = PageSizeBlockList
PageSizePrivatePhotoGrantees = 12
PageSizeAdminCertification = 20
PageSizeAdminFeedback = 20

View File

@ -15,7 +15,7 @@ import (
// Version of the config format - when new fields are added, it will attempt
// to write the settings.toml to disk so new defaults populate.
var currentVersion = 5
var currentVersion = 4
// Current loaded settings.json
var Current = DefaultVariable()
@ -32,7 +32,6 @@ type Variable struct {
BareRTC BareRTC
Maintenance Maintenance
Encryption Encryption
SignedPhoto SignedPhoto
WebPush WebPush
Turnstile Turnstile
UseXForwardedFor bool
@ -127,12 +126,6 @@ func LoadSettings() {
writeSettings = true
}
// Initialize JWT token for SignedPhoto feature.
if Current.SignedPhoto.JWTSecret == "" {
Current.SignedPhoto.JWTSecret = uuid.New().String()
writeSettings = true
}
// Have we added new config fields? Save the settings.json.
if Current.Version != currentVersion || writeSettings {
log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.")
@ -203,12 +196,6 @@ type Encryption struct {
ColdStorageRSAPublicKey []byte
}
// SignedPhoto settings.
type SignedPhoto struct {
Enabled bool
JWTSecret string
}
// WebPush settings.
type WebPush struct {
VAPIDPublicKey string

View File

@ -50,7 +50,7 @@ func Dashboard() http.HandlerFunc {
pager := &models.Pagination{
Page: 1,
PerPage: config.PageSizeDashboardNotifications,
Sort: "read, created_at desc",
Sort: "created_at desc",
}
pager.ParsePage(r)
notifs, err := models.PaginateNotifications(currentUser, nf, pager)

View File

@ -3,9 +3,7 @@ package account
import (
"net/http"
"net/url"
"strings"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/middleware"
"code.nonshy.com/nonshy/website/pkg/models"
@ -40,8 +38,7 @@ func Profile() http.HandlerFunc {
}
vars := map[string]interface{}{
"User": user,
"IsExternalView": true,
"User": user,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@ -73,9 +70,9 @@ func Profile() http.HandlerFunc {
// Inject relationship booleans for profile picture display.
models.SetUserRelationships(currentUser, []*models.User{user})
// Admin user (photo moderator) can always see the profile pic - but only on this page.
// Other avatar displays will show the yellow or pink shy.png if the admin is not friends or not granted.
if currentUser.HasAdminScope(config.ScopePhotoModerator) {
// Admin user can always see the profile pic - but only on this page. Other avatar displays
// will show the yellow or pink shy.png if the admin is not friends or not granted.
if currentUser.IsAdmin {
user.UserRelationship.IsFriend = true
user.UserRelationship.IsPrivateGranted = true
}
@ -104,14 +101,6 @@ func Profile() http.HandlerFunc {
log.Error("WhoLikes(user %d): %s", user.ID, err)
}
// Chat Moderation Rule: count of rules applied to the user, for admin view.
var chatModerationRules int
if currentUser.HasAdminScope(config.ScopeChatModerator) {
if rules := user.GetProfileField("chat_moderation_rules"); len(rules) > 0 {
chatModerationRules = len(strings.Split(rules, ","))
}
}
vars := map[string]interface{}{
"User": user,
"LikeMap": likeMap,
@ -127,9 +116,6 @@ func Profile() http.HandlerFunc {
"LikeRemainder": likeRemainder,
"LikeTableName": "users",
"LikeTableID": user.ID,
// Admin numbers.
"NumChatModerationRules": chatModerationRules,
}
if err := tmpl.Execute(w, r, vars); err != nil {

View File

@ -135,25 +135,17 @@ func ForgotPassword() http.HandlerFunc {
return
}
// Email them their reset link -- if not banned.
if !user.IsBanned() {
if err := mail.LockSending("reset_password", user.Email, config.EmailDebounceResetPassword); err == nil {
if err := mail.Send(mail.Message{
To: user.Email,
Subject: "Reset your forgotten password",
Template: "email/reset_password.html",
Data: map[string]interface{}{
"Username": user.Username,
"URL": config.Current.BaseURL + "/forgot-password?token=" + token.Token,
},
}); err != nil {
session.FlashError(w, r, "Error sending an email: %s", err)
}
} else {
log.Error("LockSending: reset_password e-mail is not sent to %s: one was sent recently", user.Email)
}
} else {
log.Error("Do not send 'forgot password' e-mail to %s: user is banned", user.Email)
// Email them their reset link.
if err := mail.Send(mail.Message{
To: user.Email,
Subject: "Reset your forgotten password",
Template: "email/reset_password.html",
Data: map[string]interface{}{
"Username": user.Username,
"URL": config.Current.BaseURL + "/forgot-password?token=" + token.Token,
},
}); err != nil {
session.FlashError(w, r, "Error sending an email: %s", err)
}
// Success message and redirect away.

View File

@ -6,7 +6,6 @@ import (
"strconv"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/controller/chat"
"code.nonshy.com/nonshy/website/pkg/geoip"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
@ -45,7 +44,6 @@ func Search() http.HandlerFunc {
hereFor = r.FormValue("here_for")
friendSearch = r.FormValue("friends") == "true"
likedSearch = r.FormValue("liked") == "true"
onChatSearch = r.FormValue("on_chat") == "true"
sort = r.FormValue("sort")
sortOK bool
)
@ -151,17 +149,6 @@ func Search() http.HandlerFunc {
certifiedOnly = true
}
// Are we filtering for "On Chat?"
var inUsername = []string{}
if onChatSearch {
stats := chat.FilteredChatStatistics(currentUser)
inUsername = stats.Usernames
if len(inUsername) == 0 {
session.FlashError(w, r, "Notice: you wanted to filter by people currently on the chat room, but nobody is on chat at this time.")
inUsername = []string{"@"}
}
}
pager := &models.Pagination{
PerPage: config.PageSizeMemberSearch,
Sort: sort,
@ -170,7 +157,6 @@ func Search() http.HandlerFunc {
users, err := models.SearchUsers(currentUser, &models.UserSearch{
Username: username,
InUsername: inUsername,
Gender: gender,
Orientation: orientation,
MaritalStatus: maritalStatus,
@ -227,7 +213,6 @@ func Search() http.HandlerFunc {
"AgeMax": ageMax,
"FriendSearch": friendSearch,
"LikedSearch": likedSearch,
"OnChatSearch": onChatSearch,
"Sort": sort,
// Restricted Search errors.

View File

@ -42,8 +42,7 @@ func Settings() http.HandlerFunc {
var reHexColor = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vars := map[string]interface{}{
"Enum": config.ProfileEnums,
"WebsiteThemeHueChoices": config.WebsiteThemeHueChoices,
"Enum": config.ProfileEnums,
}
// Load the current user in case of updates.
@ -63,10 +62,6 @@ func Settings() http.HandlerFunc {
// Are we POSTing?
if r.Method == http.MethodPost {
// Will they BECOME a Shy Account with this change?
var wasShy = user.IsShy()
intent := r.PostFormValue("intent")
switch intent {
case "profile":
@ -187,27 +182,12 @@ func Settings() http.HandlerFunc {
for _, field := range []string{
"hero-text-dark",
"card-lightness",
"website-theme", // light, dark, auto
"website-theme",
} {
value := r.PostFormValue(field)
user.SetProfileField(field, value)
}
// Website theme color: constrain to available options.
for _, field := range []struct {
Name string
Options []config.OptGroup
}{
{"website-theme-hue", config.WebsiteThemeHueChoices},
} {
value := utility.StringInOptGroup(
r.PostFormValue(field.Name),
field.Options,
"",
)
user.SetProfileField(field.Name, value)
}
if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err)
}
@ -240,7 +220,6 @@ func Settings() http.HandlerFunc {
var (
visibility = models.UserVisibility(r.PostFormValue("visibility"))
dmPrivacy = r.PostFormValue("dm_privacy")
ppPrivacy = r.PostFormValue("private_photo_gate")
)
user.Visibility = models.UserVisibilityPublic
@ -253,7 +232,6 @@ func Settings() http.HandlerFunc {
// Set profile field prefs.
user.SetProfileField("dm_privacy", dmPrivacy)
user.SetProfileField("private_photo_gate", ppPrivacy)
if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err)
@ -494,10 +472,8 @@ func Settings() http.HandlerFunc {
}
// Maybe kick them from the chat room if they had become a Shy Account.
if !wasShy && user.IsShy() {
if _, err := chat.MaybeDisconnectUser(user); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
}
if _, err := chat.MaybeDisconnectUser(user); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
}
templates.Redirect(w, r.URL.Path+hashtag+".")

View File

@ -139,29 +139,24 @@ func Signup() http.HandlerFunc {
}
// Already an account?
if user, err := models.FindUser(email); err == nil {
if _, err := models.FindUser(email); err == nil {
// We don't want to admit that the email already is registered, so send an email to the
// address in case the user legitimately forgot, but flash the regular success message.
if user.IsBanned() {
log.Error("Do not send signup e-mail to %s: user is banned", email)
} else {
if err := mail.LockSending("signup", email, config.EmailDebounceDefault); err == nil {
err := mail.Send(mail.Message{
To: email,
Subject: "You already have a nonshy account",
Template: "email/already_signed_up.html",
Data: map[string]interface{}{
"Title": config.Title,
"URL": config.Current.BaseURL + "/forgot-password",
},
})
if err != nil {
session.FlashError(w, r, "Error sending an email: %s", err)
}
} else {
log.Error("LockSending: signup e-mail is not sent to %s: one was sent recently", email)
if err := mail.LockSending("signup", email, config.SignupTokenExpires); err == nil {
err := mail.Send(mail.Message{
To: email,
Subject: "You already have a nonshy account",
Template: "email/already_signed_up.html",
Data: map[string]interface{}{
"Title": config.Title,
"URL": config.Current.BaseURL + "/forgot-password",
},
})
if err != nil {
session.FlashError(w, r, "Error sending an email: %s", err)
}
} else {
log.Error("LockSending: signup e-mail is not sent to %s: one was sent recently", email)
}
session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email)
@ -273,11 +268,6 @@ func Signup() http.HandlerFunc {
user.Birthdate = birthdate
user.Save()
// Restore their block lists if this user has deleted their account before.
if err := models.RestoreDeletedUserMemory(user); err != nil {
log.Error("RestoreDeletedUserMemory(%s): %s", user.Username, err)
}
// Log in the user and send them to their dashboard.
session.LoginUser(w, r, user)
templates.Redirect(w, "/me")

View File

@ -18,10 +18,7 @@ func UserNotes() http.HandlerFunc {
tmpl := templates.Must("account/user_notes.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the username out of the URL parameters.
var (
username = r.PathValue("username")
show = r.FormValue("show") // admin feedback filter
)
var username = r.PathValue("username")
// Find this user.
user, err := models.FindUser(username)
@ -111,7 +108,7 @@ func UserNotes() http.HandlerFunc {
}
// Paginate feedback & reports.
if fb, err := models.PaginateFeedbackAboutUser(user, show, fbPager); err != nil {
if fb, err := models.PaginateFeedbackAboutUser(user, fbPager); err != nil {
session.FlashError(w, r, "Paginating feedback on this user: %s", err)
} else {
feedback = fb
@ -144,7 +141,6 @@ func UserNotes() http.HandlerFunc {
"MyNote": myNote,
// Admin concerns.
"Show": show,
"Feedback": feedback,
"FeedbackPager": fbPager,
"OtherNotes": otherNotes,

View File

@ -14,13 +14,6 @@ import (
// Feedback controller (/admin/feedback)
func Feedback() http.HandlerFunc {
tmpl := templates.Must("admin/feedback.html")
// Whitelist for ordering options.
var sortWhitelist = []string{
"created_at desc",
"created_at asc",
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query params.
var (
@ -30,26 +23,8 @@ func Feedback() http.HandlerFunc {
profile = r.FormValue("profile") == "true" // visit associated user profile
verdict = r.FormValue("verdict")
fb *models.Feedback
// Search filters.
searchQuery = r.FormValue("q")
search = models.ParseSearchString(searchQuery)
subject = r.FormValue("subject")
sort = r.FormValue("sort")
sortOK bool
)
// Sort options.
for _, v := range sortWhitelist {
if sort == v {
sortOK = true
break
}
}
if !sortOK {
sort = sortWhitelist[0]
}
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Couldn't get your current user: %s", err)
@ -69,15 +44,6 @@ func Feedback() http.HandlerFunc {
// Are we visiting a linked resource (via TableID)?
if fb != nil && fb.TableID > 0 && visit {
// New (Oct 17 '24): feedbacks may carry an AboutUserID, e.g. for photos in case the reported
// photo is removed then the associated owner of the photo is still carried in the report.
var aboutUser *models.User
if fb.AboutUserID > 0 {
if user, err := models.GetUser(fb.AboutUserID); err == nil {
aboutUser = user
}
}
switch fb.TableName {
case "users":
user, err := models.GetUser(fb.TableID)
@ -90,29 +56,15 @@ func Feedback() http.HandlerFunc {
case "photos":
pic, err := models.GetPhoto(fb.TableID)
if err != nil {
// If there was an About User, visit their profile page instead.
if aboutUser != nil {
session.FlashError(w, r, "The photo #%d was deleted, visiting the owner's profile page instead.", fb.TableID)
templates.Redirect(w, "/u/"+aboutUser.Username)
return
}
session.FlashError(w, r, "Couldn't get photo %d: %s", fb.TableID, err)
} else {
// Going to the user's profile page?
if profile {
// Going forward: the aboutUser will be populated, this is for legacy reports.
if aboutUser == nil {
if user, err := models.GetUser(pic.UserID); err == nil {
aboutUser = user
} else {
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
}
}
if aboutUser != nil {
templates.Redirect(w, "/u/"+aboutUser.Username)
user, err := models.GetUser(pic.UserID)
if err != nil {
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
} else {
templates.Redirect(w, "/u/"+user.Username)
return
}
}
@ -137,9 +89,19 @@ func Feedback() http.HandlerFunc {
}
}
case "comments":
// Redirect to the comment redirector.
templates.Redirect(w, fmt.Sprintf("/go/comment?id=%d", fb.TableID))
return
// Get this comment.
comment, err := models.GetComment(fb.TableID)
if err != nil {
session.FlashError(w, r, "Couldn't get comment ID %d: %s", fb.TableID, err)
} else {
// What was the comment on?
switch comment.TableName {
case "threads":
// Visit the thread.
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", comment.TableID))
return
}
}
case "forums":
// Get this forum.
forum, err := models.GetForum(fb.TableID)
@ -187,51 +149,31 @@ func Feedback() http.HandlerFunc {
pager := &models.Pagination{
Page: 1,
PerPage: config.PageSizeAdminFeedback,
Sort: sort,
Sort: "updated_at desc",
}
pager.ParsePage(r)
page, err := models.PaginateFeedback(acknowledged, intent, subject, search, pager)
page, err := models.PaginateFeedback(acknowledged, intent, pager)
if err != nil {
session.FlashError(w, r, "Couldn't load feedback from DB: %s", err)
}
// Map user IDs.
var (
userIDs = []uint64{}
photoIDs = []uint64{}
)
var userIDs = []uint64{}
for _, p := range page {
if p.UserID > 0 {
userIDs = append(userIDs, p.UserID)
}
if p.TableName == "photos" && p.TableID > 0 {
photoIDs = append(photoIDs, p.TableID)
}
}
userMap, err := models.MapUsers(currentUser, userIDs)
if err != nil {
session.FlashError(w, r, "Couldn't map user IDs: %s", err)
}
// Map photo IDs.
photoMap, err := models.MapPhotos(photoIDs)
if err != nil {
session.FlashError(w, r, "Couldn't map photo IDs: %s", err)
}
var vars = map[string]interface{}{
// Filter settings.
"DistinctSubjects": models.DistinctFeedbackSubjects(),
"SearchTerm": searchQuery,
"Subject": subject,
"Sort": sort,
"Intent": intent,
"Acknowledged": acknowledged,
"Feedback": page,
"UserMap": userMap,
"PhotoMap": photoMap,
"Pager": pager,
}
if err := tmpl.Execute(w, r, vars); err != nil {

View File

@ -52,7 +52,6 @@ func MarkPhotoExplicit() http.HandlerFunc {
}
photo.Explicit = true
photo.Flagged = true
if err := photo.Save(); err != nil {
session.FlashError(w, r, "Couldn't save photo: %s", err)
} else {
@ -118,60 +117,11 @@ func UserActions() http.HandlerFunc {
return
}
// Get their block lists.
insights, err := models.GetBlocklistInsights(user)
if err != nil {
session.FlashError(w, r, "Error getting blocklist insights: %s", err)
}
vars["BlocklistInsights"] = insights
// Also surface counts of admin blocks.
count, total := models.CountBlockedAdminUsers(user)
vars["AdminBlockCount"] = count
vars["AdminBlockTotal"] = total
case "chat.rules":
// Chat Moderation Rules.
if !currentUser.HasAdminScope(config.ScopeChatModerator) {
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeChatModerator)
templates.Redirect(w, "/admin")
return
}
if r.Method == http.MethodPost {
// Rules list for the change log.
var newRules = "(none)"
if rule, ok := r.PostForm["rules"]; ok && len(rule) > 0 {
newRules = strings.Join(rule, ",")
user.SetProfileField("chat_moderation_rules", newRules)
if err := user.Save(); err != nil {
session.FlashError(w, r, "Error saving the user's chat rules: %s", err)
} else {
session.Flash(w, r, "Chat moderation rules have been updated!")
}
} else {
user.DeleteProfileField("chat_moderation_rules")
session.Flash(w, r, "All chat moderation rules have been cleared for user: %s", user.Username)
}
templates.Redirect(w, "/u/"+user.Username)
// Log the new rules to the changelog.
models.LogEvent(
user,
currentUser,
"updated",
"chat.rules",
user.ID,
fmt.Sprintf(
"An admin has updated the chat moderation rules for this user.\n\n"+
"The update rules are: %s",
newRules,
),
)
return
}
vars["ChatModerationRules"] = config.ChatModerationRules
case "essays":
// Edit their profile essays easily.
if !currentUser.HasAdminScope(config.ScopePhotoModerator) {

View File

@ -79,9 +79,6 @@ func Report() http.HandlerFunc {
log.Debug("Got chat report: %+v", report)
// Make a clickable profile link for the channel ID (other user).
otherUsername := strings.TrimPrefix(report.Channel, "@")
// Create an admin Feedback model.
fb := &models.Feedback{
Intent: "report",
@ -90,7 +87,7 @@ func Report() http.HandlerFunc {
"A message was reported on the chat room!\n\n"+
"* From username: [%s](/u/%s)\n"+
"* About username: [%s](/u/%s)\n"+
"* Channel: [**%s**](/u/%s)\n"+
"* Channel: **%s**\n"+
"* Timestamp: %s\n"+
"* Classification: %s\n"+
"* User comment: %s\n\n"+
@ -98,7 +95,7 @@ func Report() http.HandlerFunc {
"The reported message on chat was:\n\n%s",
report.FromUsername, report.FromUsername,
report.AboutUsername, report.AboutUsername,
report.Channel, otherUsername,
report.Channel,
report.Timestamp,
report.Reason,
report.Comment,
@ -119,7 +116,6 @@ func Report() http.HandlerFunc {
if err == nil {
fb.TableName = "users"
fb.TableID = targetUser.ID
fb.AboutUserID = targetUser.ID
} else {
log.Error("BareRTC Chat Feedback: couldn't find user ID for AboutUsername=%s: %s", report.AboutUsername, err)
}

View File

@ -8,7 +8,6 @@ import (
"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/photo"
"code.nonshy.com/nonshy/website/pkg/session"
)
@ -99,18 +98,26 @@ func Likes() http.HandlerFunc {
if user, err := models.GetUser(photo.UserID); err == nil {
// Safety check: if the current user should not see this picture, they can not "Like" it.
// Example: you unfriended them but they still had the image on their old browser page.
if ok, _ := photo.ShouldBeSeenBy(currentUser); !ok {
var unallowed bool
if currentUser.ID != user.ID {
if (photo.Visibility == models.PhotoFriends && !models.AreFriends(user.ID, currentUser.ID)) ||
(photo.Visibility == models.PhotoPrivate && !models.IsPrivateUnlocked(user.ID, currentUser.ID)) {
unallowed = true
}
}
// Blocking safety check: if either user blocks the other, liking is not allowed.
if models.IsBlocking(currentUser.ID, user.ID) {
unallowed = true
}
if unallowed {
SendJSON(w, http.StatusForbidden, Response{
Error: "You are not allowed to like that photo.",
})
return
}
// Mark this photo as 'viewed' if it received a like.
// Example: on a gallery view the photo is only 'viewed' if interacted with (lightbox),
// going straight for the 'Like' button should count as well.
photo.View(currentUser)
targetUser = user
}
} else {
@ -197,13 +204,6 @@ func Likes() http.HandlerFunc {
}
}
// Refresh cached like counts.
if req.TableName == "photos" {
if err := models.UpdatePhotoCachedCounts(tableID); err != nil {
log.Error("UpdatePhotoCachedCount(%d): %s", tableID, err)
}
}
// Send success response.
SendJSON(w, http.StatusOK, Response{
OK: true,
@ -286,7 +286,7 @@ func WhoLikes() http.HandlerFunc {
for _, user := range users {
result = append(result, Liker{
Username: user.Username,
Avatar: photo.VisibleAvatarURL(user, currentUser),
Avatar: user.VisibleAvatarURL(currentUser),
Relationship: user.UserRelationship,
})
}

View File

@ -83,7 +83,6 @@ func MarkPhotoExplicit() http.HandlerFunc {
}
photo.Explicit = true
photo.Flagged = true
if err := photo.Save(); err != nil {
SendJSON(w, http.StatusBadRequest, Response{
Error: fmt.Sprintf("Couldn't save the photo: %s", err),
@ -91,23 +90,6 @@ func MarkPhotoExplicit() http.HandlerFunc {
return
}
// Send the photo's owner a notification so they are aware.
if owner, err := models.GetUser(photo.UserID); err == nil {
notif := &models.Notification{
UserID: owner.ID,
AboutUser: *owner,
Type: models.NotificationExplicitPhoto,
TableName: "photos",
TableID: photo.ID,
Link: fmt.Sprintf("/photo/view?id=%d", photo.ID),
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create Likes notification: %s", err)
}
} else {
log.Error("MarkExplicit: getting photo owner (%d): %s", photo.UserID, err)
}
// If a non-admin user has hit this API, log an admin report for visibility and
// to keep a pulse on things (e.g. in case of abuse).
if !currentUser.IsAdmin {

View File

@ -1,70 +0,0 @@
package api
import (
"net/http"
"strconv"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
)
// ViewPhoto API pings a view count on a photo, e.g. from the lightbox modal.
func ViewPhoto() http.HandlerFunc {
// Response JSON schema.
type Response struct {
OK bool `json:"OK"`
Error string `json:"error,omitempty"`
Likes int64 `json:"likes"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
SendJSON(w, http.StatusBadRequest, Response{
Error: "Couldn't get current user!",
})
return
}
// Photo ID from path parameter.
var photoID uint64
if id, err := strconv.Atoi(r.PathValue("photo_id")); err == nil && id > 0 {
photoID = uint64(id)
} else {
SendJSON(w, http.StatusBadRequest, Response{
Error: "Invalid photo ID",
})
return
}
// Find this photo.
photo, err := models.GetPhoto(photoID)
if err != nil {
SendJSON(w, http.StatusNotFound, Response{
Error: "Photo Not Found",
})
return
}
// Check permission to have seen this photo.
if ok, err := photo.ShouldBeSeenBy(currentUser); !ok {
log.Error("Photo %d can't be seen by %s: %s", photo.ID, currentUser.Username, err)
SendJSON(w, http.StatusNotFound, Response{
Error: "Photo Not Found",
})
return
}
// Mark a view.
if err := photo.View(currentUser); err != nil {
log.Error("Update photo(%d) views: %s", photo.ID, err)
}
// Send success response.
SendJSON(w, http.StatusOK, Response{
OK: true,
})
})
}

View File

@ -1,106 +0,0 @@
package api
import (
"fmt"
"net/http"
"net/url"
"strings"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/encryption"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/photo"
"code.nonshy.com/nonshy/website/pkg/session"
)
// PhotoSignAuth API protects paths like /static/photos/ to authenticated user requests only.
func PhotoSignAuth() http.HandlerFunc {
type Response struct {
Success bool `json:"success"`
Error string `json:",omitempty"`
Username string `json:"username"`
}
logAndError := func(w http.ResponseWriter, m string, v ...interface{}) {
log.Debug("ERROR PhotoSignAuth: "+m, v...)
SendJSON(w, http.StatusForbidden, Response{
Error: fmt.Sprintf(m, v...),
})
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// We only protect the /static/photos subpath.
// And check if the SignedPhoto feature is enabled and enforcing.
var originalURI = r.Header.Get("X-Original-URI")
if !config.Current.SignedPhoto.Enabled || !strings.HasPrefix(originalURI, config.PhotoWebPath) {
SendJSON(w, http.StatusOK, Response{
Success: true,
})
return
}
// Get the base filename.
var filename = strings.TrimPrefix(
strings.SplitN(originalURI, config.PhotoWebPath, 2)[1],
"/",
)
filename = strings.SplitN(filename, "?", 2)[0] // inner query string too
// Parse the JWT token parameter from the original URL.
var token string
if path, err := url.Parse(originalURI); err == nil {
query := path.Query()
token = query.Get("jwt")
}
// The JWT token is required from here on out.
if token == "" {
logAndError(w, "JWT token is required")
return
}
// Check if we're logged in and who the current username is.
var username string
if currentUser, err := session.CurrentUser(r); err == nil {
username = currentUser.Username
}
// Validate the JWT token is correctly signed and not expired.
claims, ok, err := encryption.ValidateClaims(
token,
[]byte(config.Current.SignedPhoto.JWTSecret),
&photo.SignedPhotoClaims{},
)
if !ok || err != nil {
logAndError(w, "When validating JWT claims: %v", err)
return
}
// Parse the claims to get data to validate this request.
c, ok := claims.(*photo.SignedPhotoClaims)
if !ok {
logAndError(w, "JWT claims were not the correct shape: %+v", claims)
return
}
// Was the signature for our username? (Skip if for Anyone)
if !c.Anyone && c.Subject != username {
logAndError(w, "That token did not belong to you")
return
}
// Is the file name correct?
hash := photo.FilenameHash(filename)
if hash != c.FilenameHash {
logAndError(w, "Filename hash mismatch: fn=%s hash=%s jwt=%s", filename, hash, c.FilenameHash)
return
}
log.Debug("PhotoSignAuth: JWT Signature OK! fn=%s u=%s anyone=%v expires=%+v", filename, c.Subject, c.Anyone, c.ExpiresAt)
SendJSON(w, http.StatusOK, Response{
Success: true,
Username: username,
})
})
}

View File

@ -112,35 +112,17 @@ func BlockUser() http.HandlerFunc {
return
}
// If the target user is an admin, log this to the admin reports page.
if user.IsAdmin {
// Is the target admin user unblockable?
var (
unblockable = user.HasAdminScope(config.ScopeUnblockable)
footer string // qualifier for the admin report body
)
// Add a footer to the report to indicate whether the block goes through.
if unblockable {
footer = "**Unblockable:** this admin can not be blocked, so the block was not added and the user was shown an error message."
} else {
footer = "**Notice:** This admin is not unblockable, so the block has been added successfully."
}
// Also, include this user's current count of blocked admin users.
count, total := models.CountBlockedAdminUsers(currentUser)
footer += fmt.Sprintf("\n\nThis user now blocks %d of %d admin user(s) on this site.", count+1, total)
// Can't block admins who have the unblockable scope.
if user.IsAdmin && user.HasAdminScope(config.ScopeUnblockable) {
// For curiosity's sake, log a report.
fb := &models.Feedback{
Intent: "report",
Subject: "A user tried to block an admin",
Message: fmt.Sprintf(
"A user has tried to block an admin user account!\n\n"+
"* Username: %s\n* Tried to block: %s\n\n%s",
"* Username: %s\n* Tried to block: %s",
currentUser.Username,
user.Username,
footer,
),
UserID: currentUser.ID,
TableName: "users",
@ -150,12 +132,9 @@ func BlockUser() http.HandlerFunc {
log.Error("Could not log feedback for user %s trying to block admin %s: %s", currentUser.Username, user.Username, err)
}
// If the admin is unblockable, give the user an error message and return.
if unblockable {
session.FlashError(w, r, "You can not block site administrators.")
templates.Redirect(w, "/u/"+username)
return
}
session.FlashError(w, r, "You can not block site administrators.")
templates.Redirect(w, "/u/"+username)
return
}
// Block the target user.

View File

@ -3,6 +3,7 @@ package chat
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
@ -10,7 +11,6 @@ import (
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/encryption"
"code.nonshy.com/nonshy/website/pkg/geoip"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/middleware"
@ -22,17 +22,16 @@ import (
"github.com/golang-jwt/jwt/v4"
)
// Claims are the JWT claims for the BareRTC chat room.
// JWT claims.
type Claims struct {
// Custom claims.
IsAdmin bool `json:"op,omitempty"`
VIP bool `json:"vip,omitempty"`
Avatar string `json:"img,omitempty"`
ProfileURL string `json:"url,omitempty"`
Nickname string `json:"nick,omitempty"`
Emoji string `json:"emoji,omitempty"`
Gender string `json:"gender,omitempty"`
Rules []string `json:"rules,omitempty"`
IsAdmin bool `json:"op,omitempty"`
VIP bool `json:"vip,omitempty"`
Avatar string `json:"img,omitempty"`
ProfileURL string `json:"url,omitempty"`
Nickname string `json:"nick,omitempty"`
Emoji string `json:"emoji,omitempty"`
Gender string `json:"gender,omitempty"`
// Standard claims. Notes:
// subject = username
@ -77,6 +76,16 @@ func Landing() http.HandlerFunc {
return
}
// If we are shy, block chat for now.
if isShy {
session.FlashError(w, r,
"You have a Shy Account and are not allowed in the chat room at this time where our non-shy members may "+
"be on camera.",
)
templates.Redirect(w, "/chat")
return
}
// Get our Chat JWT secret.
var (
secret = []byte(config.Current.BareRTC.JWTSecret)
@ -89,7 +98,7 @@ func Landing() http.HandlerFunc {
}
// Avatar URL - masked if non-public.
avatar := photo.SignedPublicAvatarURL(currentUser.ProfilePhoto.CroppedFilename)
avatar := photo.URLPath(currentUser.ProfilePhoto.CroppedFilename)
switch currentUser.ProfilePhoto.Visibility {
case models.PhotoPrivate:
avatar = "/static/img/shy-private.png"
@ -111,29 +120,25 @@ func Landing() http.HandlerFunc {
emoji = "🍰 It's my birthday!"
}
// Apply chat moderation rules.
var rules = []string{}
if isShy {
// Shy account: no camera privileges.
rules = []string{"novideo", "noimage"}
} else if v := currentUser.GetProfileField("chat_moderation_rules"); len(v) > 0 {
// Specific mod rules applied to the current user.
rules = strings.Split(v, ",")
}
// Create the JWT claims.
claims := Claims{
IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator),
Avatar: avatar,
ProfileURL: "/u/" + currentUser.Username,
Nickname: currentUser.NameOrUsername(),
Emoji: emoji,
Gender: Gender(currentUser),
VIP: isShy, // "shy accounts" use the "VIP" status for special icon in chat
Rules: rules,
RegisteredClaims: encryption.StandardClaims(currentUser.ID, currentUser.Username, time.Now().Add(5*time.Minute)),
IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator),
Avatar: avatar,
ProfileURL: "/u/" + currentUser.Username,
Nickname: currentUser.NameOrUsername(),
Emoji: emoji,
Gender: Gender(currentUser),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: config.Title,
Subject: currentUser.Username,
ID: fmt.Sprintf("%d", currentUser.ID),
},
}
token, err := encryption.SignClaims(claims, []byte(config.Current.BareRTC.JWTSecret))
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
ss, err := token.SignedString(secret)
if err != nil {
session.FlashError(w, r, "Couldn't sign you into the chat: %s", err)
templates.Redirect(w, r.URL.Path)
@ -149,15 +154,8 @@ 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="+token)
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss)
return
}

View File

@ -121,14 +121,6 @@ func PostComment() http.HandlerFunc {
// Log the change.
models.LogDeleted(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, "Deleted a comment.", comment)
}
// Refresh cached like counts.
if tableName == "photos" {
if err := models.UpdatePhotoCachedCounts(tableID); err != nil {
log.Error("UpdatePhotoCachedCount(%d): %s", tableID, err)
}
}
templates.Redirect(w, fromURL)
return
}
@ -182,13 +174,6 @@ func PostComment() http.HandlerFunc {
session.Flash(w, r, "Comment added!")
templates.Redirect(w, fromURL)
// Refresh cached comment counts.
if tableName == "photos" {
if err := models.UpdatePhotoCachedCounts(tableID); err != nil {
log.Error("UpdatePhotoCachedCount(%d): %s", tableID, err)
}
}
// Log the change.
models.LogCreated(currentUser, "comments", comment.ID, "Posted a new comment.\n\n---\n\n"+message)

View File

@ -77,6 +77,7 @@ func AddEdit() http.HandlerFunc {
// Sanity check admin-only settings -> default these to OFF.
if !currentUser.HasAdminScope(config.ScopeForumAdmin) {
isPrivileged = false
isPermitPhotos = false
isPrivate = false
}
@ -87,23 +88,23 @@ func AddEdit() http.HandlerFunc {
models.NewFieldDiff("Description", forum.Description, description),
models.NewFieldDiff("Category", forum.Category, category),
models.NewFieldDiff("Explicit", forum.Explicit, isExplicit),
models.NewFieldDiff("PermitPhotos", forum.PermitPhotos, isPermitPhotos),
}
forum.Title = title
forum.Description = description
forum.Category = category
forum.Explicit = isExplicit
forum.PermitPhotos = isPermitPhotos
// Forum Admin-only options: if the current viewer is not a forum admin, do not change these settings.
// e.g.: the front-end checkboxes are hidden and don't want to accidentally unset these!
if currentUser.HasAdminScope(config.ScopeForumAdmin) {
diffs = append(diffs,
models.NewFieldDiff("Privileged", forum.Privileged, isPrivileged),
models.NewFieldDiff("PermitPhotos", forum.PermitPhotos, isPermitPhotos),
models.NewFieldDiff("Private", forum.Private, isPrivate),
)
forum.Privileged = isPrivileged
forum.PermitPhotos = isPermitPhotos
forum.Private = isPrivate
}

View File

@ -55,7 +55,7 @@ func Forum() http.HandlerFunc {
var pager = &models.Pagination{
Page: 1,
PerPage: config.PageSizeThreadList,
Sort: "threads.updated_at desc",
Sort: "updated_at desc",
}
pager.ParsePage(r)

View File

@ -47,10 +47,6 @@ func NewPost() http.HandlerFunc {
// well (pinned, explicit, noreply)
isOriginalComment bool
// If neither the forum nor thread are explicit, show a hint to the user not to
// share an explicit photo in their reply.
explicitPhotoAllowed bool
// Polls
pollOptions = []string{}
pollExpires = 3
@ -91,11 +87,6 @@ func NewPost() http.HandlerFunc {
}
}
// Would an explicit photo attachment be allowed?
if forum.Explicit || (thread != nil && thread.Explicit) {
explicitPhotoAllowed = true
}
// If the current user can moderate the forum thread, e.g. edit or delete posts.
// Admins can edit always, user owners of forums can only delete.
var canModerate = currentUser.HasAdminScope(config.ScopeForumModerator) ||
@ -118,14 +109,7 @@ func NewPost() http.HandlerFunc {
if len(quoteCommentID) > 0 {
if i, err := strconv.Atoi(quoteCommentID); err == nil {
if comment, err := models.GetComment(uint64(i)); err == nil {
// Prefill the message with the @mention and quoted post.
message = fmt.Sprintf(
"[@%s](/go/comment?id=%d)\n\n%s\n\n",
comment.User.Username,
comment.ID,
markdown.Quotify(comment.Message),
)
message = markdown.Quotify(comment.Message) + "\n\n"
}
}
}
@ -502,14 +486,13 @@ func NewPost() http.HandlerFunc {
}
var vars = map[string]interface{}{
"Forum": forum,
"Thread": thread,
"Intent": intent,
"PostTitle": title,
"EditCommentID": editCommentID,
"EditThreadSettings": isOriginalComment,
"ExplicitPhotoAllowed": explicitPhotoAllowed,
"Message": message,
"Forum": forum,
"Thread": thread,
"Intent": intent,
"PostTitle": title,
"EditCommentID": editCommentID,
"EditThreadSettings": isOriginalComment,
"Message": message,
// Thread settings (for editing the original comment esp.)
"IsPinned": isPinned,
@ -517,9 +500,7 @@ func NewPost() http.HandlerFunc {
"IsNoReply": isNoReply,
// Polls
"PollOptions": pollOptions,
"PollExpires": pollExpires,
"PollExpiresOptions": config.PollExpires,
"PollOptions": pollOptions,
// Attached photo.
"CommentPhoto": commentPhoto,

View File

@ -20,10 +20,6 @@ func Thread() http.HandlerFunc {
idStr = r.PathValue("id")
forum *models.Forum
thread *models.Thread
// If neither the forum nor thread are explicit, show a hint to the user not to
// share an explicit photo in their reply.
explicitPhotoAllowed bool
)
if idStr == "" {
@ -65,11 +61,6 @@ func Thread() http.HandlerFunc {
// e.g. can we delete threads and posts, not edit them)
var canModerate = forum.CanBeModeratedBy(currentUser)
// Would an explicit photo attachment be allowed?
if forum.Explicit || thread.Explicit {
explicitPhotoAllowed = true
}
// Ping the view count on this thread.
if err := thread.View(currentUser.ID); err != nil {
log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err)
@ -106,24 +97,16 @@ 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,
"Comments": comments,
"LikeMap": commentLikeMap,
"PhotoMap": photos,
"Pager": pager,
"CanModerate": canModerate,
"IsSubscribed": isSubscribed,
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
"ExplicitPhotoAllowed": explicitPhotoAllowed,
"Forum": forum,
"Thread": thread,
"Comments": comments,
"LikeMap": commentLikeMap,
"PhotoMap": photos,
"Pager": pager,
"CanModerate": canModerate,
"IsSubscribed": isSubscribed,
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -26,15 +26,13 @@ func Contact() http.HandlerFunc {
subject = r.FormValue("subject")
title = "Contact Us"
message = r.FormValue("message")
footer string // appends to the message only when posting the feedback
replyTo = r.FormValue("email")
trap1 = r.FormValue("url") != "https://"
trap2 = r.FormValue("comment") != ""
tableID int
tableName string
tableLabel string // front-end user feedback about selected report item
aboutUser *models.User // associated user (e.g. owner of reported photo)
messageRequired = true // unless we have a table ID to work with
tableLabel string // front-end user feedback about selected report item
messageRequired = true // unless we have a table ID to work with
success = "Thank you for your feedback! Your message has been delivered to the website administrators."
)
@ -57,7 +55,6 @@ func Contact() http.HandlerFunc {
tableName = "users"
if user, err := models.GetUser(uint64(tableID)); err == nil {
tableLabel = fmt.Sprintf(`User account "%s"`, user.Username)
aboutUser = user
} else {
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
}
@ -68,7 +65,6 @@ func Contact() http.HandlerFunc {
if pic, err := models.GetPhoto(uint64(tableID)); err == nil {
if user, err := models.GetUser(pic.UserID); err == nil {
tableLabel = fmt.Sprintf(`A profile photo of user account "%s"`, user.Username)
aboutUser = user
} else {
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
}
@ -78,43 +74,12 @@ func Contact() http.HandlerFunc {
case "report.message":
tableName = "messages"
tableLabel = "Direct Message conversation"
// Find this message, and attach it to the report.
if msg, err := models.GetMessage(uint64(tableID)); err == nil {
var username = "[unavailable]"
if sender, err := models.GetUser(msg.SourceUserID); err == nil {
username = sender.Username
aboutUser = sender
}
footer = fmt.Sprintf(`
---
From: <a href="/u/%s">@%s</a>
%s`,
username, username,
markdown.Quotify(msg.Message),
)
}
case "report.comment":
tableName = "comments"
// Find this comment.
if comment, err := models.GetComment(uint64(tableID)); err == nil {
tableLabel = fmt.Sprintf(`A comment written by "%s"`, comment.User.Username)
aboutUser = &comment.User
footer = fmt.Sprintf(`
---
From: <a href="/u/%s">@%s</a>
%s`,
comment.User.Username, comment.User.Username,
markdown.Quotify(comment.Message),
)
} else {
log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err)
}
@ -176,15 +141,11 @@ From: <a href="/u/%s">@%s</a>
fb := &models.Feedback{
Intent: intent,
Subject: subject,
Message: message + footer,
Message: message,
TableName: tableName,
TableID: uint64(tableID),
}
if aboutUser != nil {
fb.AboutUserID = aboutUser.ID
}
if currentUser != nil && currentUser.ID > 0 {
fb.UserID = currentUser.ID
} else if replyTo != "" {

View File

@ -1,57 +0,0 @@
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,7 +5,6 @@ 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"
)
@ -19,17 +18,7 @@ func Create() http.HandlerFunc {
return
}
// 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 {
if err := tmpl.Execute(w, r, nil); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

View File

@ -1,166 +0,0 @@
package mutelist
import (
"net/http"
"strings"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// Muted User list: view the list of muted accounts.
func MuteList() http.HandlerFunc {
tmpl := templates.Must("account/mute_list.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: could not get currentUser.")
templates.Redirect(w, "/")
return
}
// Get our mutelist.
pager := &models.Pagination{
PerPage: config.PageSizeMuteList,
Sort: "updated_at desc",
}
pager.ParsePage(r)
muted, err := models.PaginateMuteList(currentUser, pager)
if err != nil {
session.FlashError(w, r, "Couldn't paginate mute list: %s", err)
templates.Redirect(w, "/")
return
}
var vars = map[string]interface{}{
"MutedUsers": muted,
"Pager": pager,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
// AddUser to manually add someone to your mute list.
func AddUser() http.HandlerFunc {
tmpl := templates.Must("account/mute_list_add.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query parameters.
var (
username = strings.ToLower(r.FormValue("username"))
next = r.FormValue("next")
context = models.MutedUserContext(r.FormValue("context"))
listName = "Site Gallery" // TODO: more as contexts are added
)
// Validate the Next URL.
if !strings.HasPrefix(next, "/") {
next = "/users/muted"
}
// Validate acceptable contexts.
if !models.IsValidMuteUserContext(context) {
session.FlashError(w, r, "Unsupported mute context.")
templates.Redirect(w, next)
return
}
// Get the target user.
user, err := models.FindUser(username)
if err != nil {
session.FlashError(w, r, "User Not Found")
templates.Redirect(w, next)
return
}
vars := map[string]interface{}{
"User": user,
"Next": next,
"Context": context,
"MuteListName": listName,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
// MuteUser controller: POST endpoint to add a mute.
func MuteUser() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Form fields
var (
username = strings.ToLower(r.PostFormValue("username"))
next = r.PostFormValue("next")
context = models.MutedUserContext(r.PostFormValue("context"))
listName = "Site Gallery" // TODO: more as contexts are added
unmute = r.PostFormValue("unmute") == "true"
)
// Validate the Next URL.
if !strings.HasPrefix(next, "/") {
next = "/users/muted"
}
// Validate acceptable contexts.
if !models.IsValidMuteUserContext(context) {
session.FlashError(w, r, "Unsupported mute context.")
templates.Redirect(w, "/")
return
}
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Couldn't get CurrentUser: %s", err)
templates.Redirect(w, "/")
return
}
// Get the target user.
user, err := models.FindUser(username)
if err != nil {
session.FlashError(w, r, "User Not Found")
templates.Redirect(w, next)
return
}
// Unmuting?
if unmute {
if err := models.RemoveMutedUser(currentUser.ID, user.ID, context); err != nil {
session.FlashError(w, r, "Couldn't unmute this user: %s.", err)
} else {
session.Flash(w, r, "You have removed %s from your %s mute list.", user.Username, listName)
// Log the change.
models.LogDeleted(currentUser, nil, "muted_users", user.ID, "Unmuted user "+user.Username+" from "+listName+".", nil)
}
templates.Redirect(w, next)
return
}
// Can't mute yourself.
if currentUser.ID == user.ID {
session.FlashError(w, r, "You can't mute yourself!")
templates.Redirect(w, next)
return
}
// Mute the target user.
if err := models.AddMutedUser(currentUser.ID, user.ID, context); err != nil {
session.FlashError(w, r, "Couldn't mute this user: %s.", err)
} else {
session.Flash(w, r, "You have added %s to your %s mute list.", user.Username, listName)
// Log the change.
models.LogCreated(currentUser, "muted_users", user.ID, "Mutes user "+user.Username+" on list "+listName+".")
}
templates.Redirect(w, next)
})
}

View File

@ -1,244 +0,0 @@
package photo
import (
"fmt"
"net/http"
"strconv"
"code.nonshy.com/nonshy/website/pkg/chat"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
pphoto "code.nonshy.com/nonshy/website/pkg/photo"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// BatchEdit controller (/photo/batch-edit?id=N) to change properties about your picture.
func BatchEdit() http.HandlerFunc {
tmpl := templates.Must("photo/batch_edit.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
// Form params
intent = r.FormValue("intent")
photoIDs []uint64
)
// Collect the photo ID params.
if value, ok := r.Form["id"]; ok {
for _, idStr := range value {
if photoID, err := strconv.Atoi(idStr); err == nil {
photoIDs = append(photoIDs, uint64(photoID))
} else {
log.Error("parsing photo ID %s: %s", idStr, err)
}
}
}
// Validation.
if len(photoIDs) == 0 || len(photoIDs) > 100 {
session.FlashError(w, r, "Invalid number of photo IDs.")
templates.Redirect(w, "/")
return
}
// Find these photos by ID.
photos, err := models.GetPhotos(photoIDs)
if err != nil {
templates.NotFoundPage(w, r)
return
}
// Load the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
templates.Redirect(w, "/")
return
}
// Validate permission to edit all of these photos.
var (
ownerIDs []uint64
)
for _, photo := range photos {
if !photo.CanBeEditedBy(currentUser) {
templates.ForbiddenPage(w, r)
return
}
ownerIDs = append(ownerIDs, photo.UserID)
}
// Load the photo owners.
var (
owners, _ = models.MapUsers(currentUser, ownerIDs)
wasShy = map[uint64]bool{} // record if this change may make them shy
redirectURI = "/" // go first owner's gallery
// Are any of them a user's profile photo? (map userID->true) so we know
// who to unlink the picture from first and avoid a postgres error.
wasUserProfilePicture = map[uint64]bool{}
)
for _, user := range owners {
redirectURI = fmt.Sprintf("/u/%s/photos", user.Username)
wasShy[user.ID] = user.IsShy()
// Check if this user's profile ID is being deleted.
if user.ProfilePhotoID != nil {
if _, ok := photos[*user.ProfilePhotoID]; ok {
wasUserProfilePicture[user.ID] = true
}
}
}
// Confirm batch deletion or edit.
if r.Method == http.MethodPost {
confirm := r.PostFormValue("confirm") == "true"
if !confirm {
session.FlashError(w, r, "Confirm you want to modify this photo.")
templates.Redirect(w, redirectURI)
return
}
// Which intent are they executing on?
switch intent {
case "delete":
batchDeletePhotos(w, r, currentUser, photos, wasUserProfilePicture, owners, redirectURI)
case "visibility":
batchUpdateVisibility(w, r, currentUser, photos, owners)
default:
session.FlashError(w, r, "Unknown intent")
}
// Maybe kick them from chat if this deletion makes them into a Shy Account.
for _, user := range owners {
user.FlushCaches()
if !wasShy[user.ID] && user.IsShy() {
if _, err := chat.MaybeDisconnectUser(user); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
}
}
}
// Return the user to their gallery.
templates.Redirect(w, redirectURI)
return
}
var vars = map[string]interface{}{
"Intent": intent,
"Photos": photos,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
// Batch DELETE executive handler.
func batchDeletePhotos(
w http.ResponseWriter,
r *http.Request,
currentUser *models.User,
photos map[uint64]*models.Photo,
wasUserProfilePicture map[uint64]bool,
owners map[uint64]*models.User,
redirectURI string,
) {
// Delete all the photos.
for _, photo := range photos {
// Was this someone's profile picture ID?
if wasUserProfilePicture[photo.UserID] {
log.Debug("Delete Photo: was the user's profile photo, unset ProfilePhotoID")
if owner, ok := owners[photo.UserID]; ok {
if err := owner.RemoveProfilePhoto(); err != nil {
session.FlashError(w, r, "Error unsetting your current profile photo: %s", err)
templates.Redirect(w, redirectURI)
return
}
}
}
// Remove the images from disk.
for _, filename := range []string{
photo.Filename,
photo.CroppedFilename,
} {
if len(filename) > 0 {
if err := pphoto.Delete(filename); err != nil {
session.FlashError(w, r, "Delete Photo: couldn't remove file from disk: %s: %s", filename, err)
}
}
}
// Take back notifications on it.
models.RemoveNotification("photos", photo.ID)
if err := photo.Delete(); err != nil {
session.FlashError(w, r, "Couldn't delete photo: %s", err)
templates.Redirect(w, redirectURI)
return
}
// Log the change.
if owner, ok := owners[photo.UserID]; ok {
models.LogDeleted(owner, currentUser, "photos", photo.ID, "Deleted the photo.", photo)
}
}
// Maybe revoke their Certified status if they have cleared out their gallery.
for _, owner := range owners {
if revoked := models.MaybeRevokeCertificationForEmptyGallery(owner); revoked {
if owner.ID == currentUser.ID {
session.FlashError(w, r, "Notice: because you have deleted your entire photo gallery, your Certification status has been automatically revoked.")
}
}
}
session.Flash(w, r, "%d photo(s) deleted!", len(photos))
}
// Batch DELETE executive handler.
func batchUpdateVisibility(
w http.ResponseWriter,
r *http.Request,
currentUser *models.User,
photos map[uint64]*models.Photo,
owners map[uint64]*models.User,
) {
// Visibility setting.
visibility := r.PostFormValue("visibility")
// Delete all the photos.
for _, photo := range photos {
// Diff for the ChangeLog.
diffs := []models.FieldDiff{
models.NewFieldDiff("Visibility", photo.Visibility, visibility),
}
photo.Visibility = models.PhotoVisibility(visibility)
// If going private, take back notifications on it.
if photo.Visibility == models.PhotoPrivate {
models.RemoveNotification("photos", photo.ID)
}
if err := photo.Save(); err != nil {
session.FlashError(w, r, "Error saving photo #%d: %s", photo.ID, err)
}
// Log the change.
if owner, ok := owners[photo.UserID]; ok {
// Log the change.
models.LogUpdated(owner, currentUser, "photos", photo.ID, "Updated the photo's settings.", diffs)
}
}
session.Flash(w, r, "%d photo(s) updated!", len(photos))
}

View File

@ -434,21 +434,17 @@ func AdminCertification() http.HandlerFunc {
}
// Notify the user via email.
if err := mail.LockSending("cert_rejected", user.Email, config.EmailDebounceDefault); err == nil {
if err := mail.Send(mail.Message{
To: user.Email,
Subject: "Your certification photo has been denied",
Template: "email/certification_rejected.html",
Data: map[string]interface{}{
"Username": user.Username,
"AdminComment": comment,
"URL": config.Current.BaseURL + "/photo/certification",
},
}); err != nil {
session.FlashError(w, r, "Note: failed to email user about the rejection: %s", err)
}
} else {
log.Error("LockSending: cert_rejected e-mail is not sent to %s: one was sent recently", user.Email)
if err := mail.Send(mail.Message{
To: user.Email,
Subject: "Your certification photo has been rejected",
Template: "email/certification_rejected.html",
Data: map[string]interface{}{
"Username": user.Username,
"AdminComment": comment,
"URL": config.Current.BaseURL + "/photo/certification",
},
}); err != nil {
session.FlashError(w, r, "Note: failed to email user about the rejection: %s", err)
}
}
@ -507,20 +503,16 @@ func AdminCertification() http.HandlerFunc {
}
// Notify the user via email.
if err := mail.LockSending("cert_approved", user.Email, config.EmailDebounceDefault); err == nil {
if err := mail.Send(mail.Message{
To: user.Email,
Subject: "Your certification photo has been approved!",
Template: "email/certification_approved.html",
Data: map[string]interface{}{
"Username": user.Username,
"URL": config.Current.BaseURL,
},
}); err != nil {
session.FlashError(w, r, "Note: failed to email user about the approval: %s", err)
}
} else {
log.Error("LockSending: cert_approved e-mail is not sent to %s: one was sent recently", user.Email)
if err := mail.Send(mail.Message{
To: user.Email,
Subject: "Your certification photo has been approved!",
Template: "email/certification_approved.html",
Data: map[string]interface{}{
"Username": user.Username,
"URL": config.Current.BaseURL,
},
}); err != nil {
session.FlashError(w, r, "Note: failed to email user about the approval: %s", err)
}
// Log the change.

View File

@ -71,9 +71,6 @@ func Edit() http.HandlerFunc {
// Are we saving the changes?
if r.Method == http.MethodPost {
// Record if this change is going to make them a Shy Account.
var wasShy = currentUser.IsShy()
var (
caption = strings.TrimSpace(r.FormValue("caption"))
altText = strings.TrimSpace(r.FormValue("alt_text"))
@ -88,9 +85,6 @@ func Edit() http.HandlerFunc {
// Are we GOING private?
goingPrivate = visibility == models.PhotoPrivate && visibility != photo.Visibility
// Is the user fighting an 'Explicit' tag added by the community?
isFightingExplicitFlag = photo.Flagged && photo.Explicit && !isExplicit
)
if len(altText) > config.AltTextMaxLength {
@ -111,24 +105,6 @@ func Edit() http.HandlerFunc {
models.NewFieldDiff("Visibility", photo.Visibility, visibility),
}
// Admin label options.
if requestUser.HasAdminScope(config.ScopePhotoModerator) {
var adminLabel string
if labels, ok := r.PostForm["admin_label"]; ok {
adminLabel = strings.Join(labels, ",")
}
diffs = append(diffs,
models.NewFieldDiff("Admin Label", photo.AdminLabel, adminLabel),
)
photo.AdminLabel = adminLabel
}
// Admin label: forced explicit?
if photo.HasAdminLabelForceExplicit() {
isExplicit = true
}
photo.Caption = caption
photo.AltText = altText
photo.Explicit = isExplicit
@ -162,34 +138,6 @@ func Edit() http.HandlerFunc {
setProfilePic = false
}
// If the user is fighting a recent Explicit flag from the community.
if isFightingExplicitFlag {
// Notify the admin (unless we are an admin).
if !requestUser.IsAdmin {
fb := &models.Feedback{
Intent: "report",
Subject: "Explicit photo flag dispute",
UserID: currentUser.ID,
TableName: "photos",
TableID: photo.ID,
Message: "A user's photo was recently **flagged by the community** as Explicit, and its owner " +
"has **removed** the Explicit setting.\n\n" +
"Please check out the photo below and verify what its Explicit setting should be:",
}
if err := models.CreateFeedback(fb); err != nil {
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
}
}
// Allow this change but clear the Flagged status.
photo.Flagged = false
// Clear the notification about this.
models.RemoveSpecificNotification(currentUser.ID, models.NotificationExplicitPhoto, "photos", photo.ID)
}
if err := photo.Save(); err != nil {
session.FlashError(w, r, "Couldn't save photo: %s", err)
}
@ -210,11 +158,8 @@ func Edit() http.HandlerFunc {
models.LogUpdated(currentUser, requestUser, "photos", photo.ID, "Updated the photo's settings.", diffs)
// Maybe kick them from the chat if this photo save makes them a Shy Account.
currentUser.FlushCaches()
if !wasShy && currentUser.IsShy() {
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
}
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
}
// If this picture has moved to Private, revoke any notification we gave about it before.
@ -232,10 +177,6 @@ func Edit() http.HandlerFunc {
"EditPhoto": photo,
"SiteGalleryThrottled": SiteGalleryThrottled,
"SiteGalleryThrottleLimit": config.SiteGalleryRateLimitMax,
// Available admin labels enum.
"RequestUser": requestUser,
"AvailableAdminLabels": config.AdminLabelPhotoOptions,
}
if err := tmpl.Execute(w, r, vars); err != nil {
@ -246,10 +187,121 @@ func Edit() http.HandlerFunc {
}
// Delete controller (/photo/Delete?id=N) to change properties about your picture.
//
// DEPRECATED: send them to the batch-edit endpoint.
func Delete() http.HandlerFunc {
// Reuse the upload page but with an EditPhoto variable.
tmpl := templates.Must("photo/delete.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
templates.Redirect(w, fmt.Sprintf("/photo/batch-edit?intent=delete&id=%s", r.FormValue("id")))
// Query params.
photoID, err := strconv.Atoi(r.FormValue("id"))
if err != nil {
log.Error("photo.Delete: failed to parse `id` param (%s) as int: %s", r.FormValue("id"), err)
session.FlashError(w, r, "Photo 'id' parameter required.")
templates.Redirect(w, "/")
return
}
// Page to redirect to in case of errors.
redirect := fmt.Sprintf("%s?id=%d", r.URL.Path, photoID)
// Find this photo by ID.
photo, err := models.GetPhoto(uint64(photoID))
if err != nil {
templates.NotFoundPage(w, r)
return
}
// Load the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
templates.Redirect(w, "/")
return
}
// In case an admin is editing this photo: remember the HTTP request current user,
// before the currentUser may be set to the photo's owner below.
var requestUser = currentUser
// Do we have permission for this photo?
if photo.UserID != currentUser.ID {
if !currentUser.HasAdminScope(config.ScopePhotoModerator) {
templates.ForbiddenPage(w, r)
return
}
// Find the owner of this photo and assume currentUser is them for the remainder
// of this controller.
if user, err := models.GetUser(photo.UserID); err != nil {
session.FlashError(w, r, "Couldn't get the owner User for this photo!")
templates.Redirect(w, "/")
return
} else {
currentUser = user
}
}
// Confirm deletion?
if r.Method == http.MethodPost {
confirm := r.PostFormValue("confirm") == "true"
if !confirm {
session.FlashError(w, r, "Confirm you want to delete this photo.")
templates.Redirect(w, redirect)
return
}
// Was this our profile picture?
if currentUser.ProfilePhotoID != nil && *currentUser.ProfilePhotoID == photo.ID {
log.Debug("Delete Photo: was the user's profile photo, unset ProfilePhotoID")
if err := currentUser.RemoveProfilePhoto(); err != nil {
session.FlashError(w, r, "Error unsetting your current profile photo: %s", err)
templates.Redirect(w, redirect)
return
}
}
// Remove the images from disk.
for _, filename := range []string{
photo.Filename,
photo.CroppedFilename,
} {
if len(filename) > 0 {
if err := pphoto.Delete(filename); err != nil {
log.Error("Delete Photo: couldn't remove file from disk: %s: %s", filename, err)
}
}
}
// Take back notifications on it.
models.RemoveNotification("photos", photo.ID)
if err := photo.Delete(); err != nil {
session.FlashError(w, r, "Couldn't delete photo: %s", err)
templates.Redirect(w, redirect)
return
}
// Log the change.
models.LogDeleted(currentUser, requestUser, "photos", photo.ID, "Deleted the photo.", photo)
session.Flash(w, r, "Photo deleted!")
// Maybe kick them from chat if this deletion makes them into a Shy Account.
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
}
// Return the user to their gallery.
templates.Redirect(w, "/u/"+currentUser.Username+"/photos")
return
}
var vars = map[string]interface{}{
"Photo": photo,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -42,12 +42,6 @@ func Private() http.HandlerFunc {
return
}
// Collect user IDs for some mappings.
var userIDs = []uint64{}
for _, user := range users {
userIDs = append(userIDs, user.ID)
}
// Map reverse grantee statuses.
var GranteeMap interface{}
if isGrantee {
@ -64,12 +58,6 @@ func Private() http.HandlerFunc {
"GranteeMap": GranteeMap,
"Users": users,
"Pager": pager,
// Mapped user statuses for frontend cards.
"PhotoCountMap": models.MapPhotoCountsByVisibility(users, models.PhotoPrivate),
"FriendMap": models.MapFriends(currentUser, users),
"LikedMap": models.MapLikes(currentUser, "users", userIDs),
"ShyMap": models.MapShyAccounts(users),
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@ -141,15 +129,6 @@ func Share() http.HandlerFunc {
intent = r.PostFormValue("intent")
)
// Is the recipient blocking this photo share?
if intent != "decline" && intent != "revoke" {
if ok, err := models.ShouldShowPrivateUnlockPrompt(currentUser, user); !ok {
session.FlashError(w, r, "You are unable to share your private photos with %s: %s.", user.Username, err)
templates.Redirect(w, "/u/"+user.Username)
return
}
}
// If submitting, do it and redirect.
if intent == "submit" {
models.UnlockPrivatePhotos(currentUser.ID, user.ID)
@ -185,21 +164,6 @@ func Share() http.HandlerFunc {
log.Error("RevokePrivatePhotoNotifications(%s): %s", currentUser.Username, err)
}
return
} else if intent == "decline" {
// Decline = they shared with me and we do not want it.
models.RevokePrivatePhotos(user.ID, currentUser.ID)
session.Flash(w, r, "You have declined access to see %s's private photos.", user.Username)
// Remove any notification we created when the grant was given.
models.RemoveSpecificNotification(currentUser.ID, models.NotificationPrivatePhoto, "__private_photos", user.ID)
// Revoke any "has uploaded a new private photo" notifications in this user's list.
if err := models.RevokePrivatePhotoNotifications(user, currentUser); err != nil {
log.Error("RevokePrivatePhotoNotifications(%s): %s", user.Username, err)
}
templates.Redirect(w, "/photo/private?view=grantee")
return
}
// The other intent is "preview" so the user gets the confirmation

View File

@ -4,7 +4,6 @@ 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"
@ -18,9 +17,6 @@ func SiteGallery() http.HandlerFunc {
var sortWhitelist = []string{
"created_at desc",
"created_at asc",
"like_count desc",
"comment_count desc",
"views desc",
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -125,13 +121,6 @@ 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,

View File

@ -19,9 +19,6 @@ func UserPhotos() http.HandlerFunc {
"pinned desc nulls last, updated_at desc",
"created_at desc",
"created_at asc",
"like_count desc",
"comment_count desc",
"views desc",
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -195,16 +192,12 @@ func UserPhotos() http.HandlerFunc {
areNotificationsMuted = !v
}
// Should the current user be able to share their private photos with the target?
showPrivateUnlockPrompt, _ := models.ShouldShowPrivateUnlockPrompt(currentUser, user)
var vars = map[string]interface{}{
"IsOwnPhotos": currentUser.ID == user.ID,
"IsShyUser": isShy,
"IsShyFrom": isShyFrom,
"IsMyPrivateUnlockedFor": isGranted, // have WE granted THIS USER to see our private pics?
"AreWeGrantedPrivate": isGrantee, // have THEY granted US private photo access.
"ShowPrivateUnlockPrompt": showPrivateUnlockPrompt,
"AreFriends": areFriends,
"AreNotificationsMuted": areNotificationsMuted,
"ProfilePictureHiddenVisibility": profilePictureHidden,

View File

@ -35,12 +35,6 @@ func View() http.HandlerFunc {
}
}
// Load the current user in case they are viewing their own page.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
}
// Find the photo's owner.
user, err := models.GetUser(photo.UserID)
if err != nil {
@ -48,10 +42,34 @@ func View() http.HandlerFunc {
return
}
if ok, err := photo.CanBeSeenBy(currentUser); !ok {
log.Error("Photo %d can't be seen by %s: %s", photo.ID, currentUser.Username, err)
session.FlashError(w, r, "Photo Not Found")
templates.Redirect(w, "/")
// Load the current user in case they are viewing their own page.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
}
var isOwnPhoto = currentUser.ID == user.ID
// Is either one blocking?
if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
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 && !isOwnPhoto {
session.FlashError(w, r, "This user's profile page and photo gallery are private.")
templates.Redirect(w, "/u/"+user.Username)
return
}
// Is this a private photo and are we allowed to see?
isGranted := models.IsPrivateUnlocked(user.ID, currentUser.ID)
if photo.Visibility == models.PhotoPrivate && !isGranted && !isOwnPhoto && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
return
}
@ -84,11 +102,6 @@ func View() http.HandlerFunc {
// Is the current user subscribed to notifications on this thread?
_, isSubscribed := models.IsSubscribed(currentUser, "photos", photo.ID)
// Mark this photo as "Viewed" by the user.
if err := photo.View(currentUser); err != nil {
log.Error("Update photo(%d) views: %s", photo.ID, err)
}
var vars = map[string]interface{}{
"IsOwnPhoto": currentUser.ID == user.ID,
"User": user,

View File

@ -1,71 +0,0 @@
package encryption
import (
"errors"
"fmt"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"github.com/golang-jwt/jwt/v4"
)
// StandardClaims returns a standard JWT claim for a username.
//
// It will include values for Subject (username), Issuer (site title), ExpiresAt, IssuedAt, NotBefore.
//
// If the userID is >0, the ID field is included.
func StandardClaims(userID uint64, username string, expiresAt time.Time) jwt.RegisteredClaims {
claim := jwt.RegisteredClaims{
Subject: username,
Issuer: config.Title,
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
}
if userID > 0 {
claim.ID = fmt.Sprintf("%d", userID)
}
return claim
}
// SignClaims creates and returns a signed JWT token.
func SignClaims(claims jwt.Claims, secret []byte) (string, error) {
// Get our Chat JWT secret.
if len(secret) == 0 {
return "", errors.New("JWT secret key is not configured")
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
ss, err := token.SignedString(secret)
if err != nil {
return "", err
}
return ss, nil
}
// ValidateClaims checks a JWT token is signed by the site key and returns the claims.
func ValidateClaims(tokenStr string, secret []byte, v jwt.Claims) (jwt.Claims, bool, error) {
// Handle a JWT authentication token.
var (
claims jwt.Claims
authOK bool
)
if tokenStr != "" {
token, err := jwt.ParseWithClaims(tokenStr, v, func(token *jwt.Token) (interface{}, error) {
return secret, nil
})
if err != nil {
return nil, false, err
}
if !token.Valid {
return nil, false, errors.New("token was not valid")
}
claims = token.Claims
authOK = true
}
return claims, authOK, nil
}

View File

@ -2,18 +2,12 @@
package markdown
import (
"regexp"
"strings"
"github.com/microcosm-cc/bluemonday"
"github.com/shurcooL/github_flavored_markdown"
)
var (
RegexpHTMLTag = regexp.MustCompile(`<(.|\n)+?>`)
RegexpMarkdownLink = regexp.MustCompile(`\[([^\[\]]*)\]\((.*?)\)`)
)
// Render markdown from untrusted sources.
func Render(input string) string {
// Render Markdown to HTML.
@ -33,28 +27,3 @@ func Quotify(input string) string {
}
return strings.Join(lines, "\n")
}
/*
DeMarkify strips some Markdown syntax from plain text snippets.
It is especially useful on Forum views that show plain text contents of posts, and especially
for linked at-mentions of the form `[@username](/go/comment?id=1234)` so that those can be
simplified down to just the `@username` text contents.
This function will strip and simplify:
- Markdown hyperlinks as spelled out above
- Literal HTML tags such as <a href="">
*/
func DeMarkify(input string) string {
// Strip all standard HTML tags.
input = RegexpHTMLTag.ReplaceAllString(input, "")
// Replace Markdown hyperlinks.
matches := RegexpMarkdownLink.FindAllStringSubmatch(input, -1)
for _, m := range matches {
input = strings.ReplaceAll(input, m[0], m[1])
}
return input
}

View File

@ -1,38 +0,0 @@
package markdown_test
import (
"testing"
"code.nonshy.com/nonshy/website/pkg/markdown"
)
func TestDeMarkify(t *testing.T) {
var cases = []struct {
Input string
Expect string
}{
{
Input: "Hello world!",
Expect: "Hello world!",
},
{
Input: "[@username](/go/comment?id=1234) Very well said!",
Expect: "@username Very well said!",
},
{
Input: `<a href="https://wikipedia.org">Wikipedia</a> said **otherwise.**`,
Expect: "Wikipedia said **otherwise.**",
},
{
Input: "[Here](/here) is one [link](https://example.com), while [Here](/here) is [another](/another).",
Expect: "Here is one link, while Here is another.",
},
}
for i, tc := range cases {
actual := markdown.DeMarkify(tc.Input)
if actual != tc.Expect {
t.Errorf("Test #%d: expected '%s' but got '%s'", i, tc.Expect, actual)
}
}
}

View File

@ -48,8 +48,9 @@ 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.PingLastLoginAt(); err != nil {
if err := user.Save(); err != nil {
log.Error("LoginRequired: couldn't refresh LastLoginAt for user %s: %s", user.Username, err)
}
}

View File

@ -1,25 +0,0 @@
package backfill
import (
"code.nonshy.com/nonshy/website/pkg/models"
)
// BackfillPhotoCounts recomputes the cached Likes and Comment counts on photos.
func BackfillPhotoCounts() error {
res := models.DB.Exec(`
UPDATE photos
SET like_count = (
SELECT count(id)
FROM likes
WHERE table_name='photos'
AND table_id=photos.id
),
comment_count = (
SELECT count(id)
FROM comments
WHERE table_name='photos'
AND table_id=photos.id
);
`)
return res.Error
}

View File

@ -166,54 +166,6 @@ func BlockedUserIDsByUser(userId uint64) []uint64 {
return userIDs
}
// GetAllBlockedUserIDs returns the forward and reverse lists of blocked user IDs for the user.
func GetAllBlockedUserIDs(user *User) (forward, reverse []uint64) {
var (
bs = []*Block{}
)
DB.Where("source_user_id = ? OR target_user_id = ?", user.ID, user.ID).Find(&bs)
for _, row := range bs {
if row.SourceUserID == user.ID {
forward = append(forward, row.TargetUserID)
} else if row.TargetUserID == user.ID {
reverse = append(reverse, row.SourceUserID)
}
}
return forward, reverse
}
// BulkRestoreBlockedUserIDs inserts many blocked user IDs in one query.
//
// Returns the count of blocks added.
func BulkRestoreBlockedUserIDs(user *User, forward, reverse []uint64) (int, error) {
var bs = []*Block{}
// Forward list.
for _, uid := range forward {
bs = append(bs, &Block{
SourceUserID: user.ID,
TargetUserID: uid,
})
}
// Reverse list.
for _, uid := range reverse {
bs = append(bs, &Block{
SourceUserID: uid,
TargetUserID: user.ID,
})
}
// Anything to do?
if len(bs) == 0 {
return 0, nil
}
// Batch create.
res := DB.Create(bs)
return len(bs), res.Error
}
// BlockedUsernames returns all usernames blocked by (or blocking) the user.
func BlockedUsernames(user *User) []string {
var (
@ -247,7 +199,6 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
reverse = []*Block{} // Users who block the target
userIDs = []uint64{user.ID}
usernames = map[uint64]string{}
admins = map[uint64]bool{}
)
// Get the complete blocklist and bucket them into forward and reverse.
@ -267,7 +218,6 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
type scanItem struct {
ID uint64
Username string
IsAdmin bool
}
var scan = []scanItem{}
if res := DB.Table(
@ -275,7 +225,6 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
).Select(
"id",
"username",
"is_admin",
).Where(
"id IN ?", userIDs,
).Scan(&scan); res.Error != nil {
@ -284,7 +233,6 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
for _, row := range scan {
usernames[row.ID] = row.Username
admins[row.ID] = row.IsAdmin
}
}
@ -297,7 +245,6 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
if username, ok := usernames[row.TargetUserID]; ok {
result.Blocks = append(result.Blocks, BlocklistInsightUser{
Username: username,
IsAdmin: admins[row.TargetUserID],
Date: row.CreatedAt,
})
}
@ -306,7 +253,6 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
if username, ok := usernames[row.SourceUserID]; ok {
result.BlockedBy = append(result.BlockedBy, BlocklistInsightUser{
Username: username,
IsAdmin: admins[row.SourceUserID],
Date: row.CreatedAt,
})
}
@ -322,7 +268,6 @@ type BlocklistInsight struct {
type BlocklistInsightUser struct {
Username string
IsAdmin bool
Date time.Time
}

View File

@ -2,10 +2,8 @@ package models
import (
"errors"
"fmt"
"time"
"code.nonshy.com/nonshy/website/pkg/log"
"gorm.io/gorm"
)
@ -103,64 +101,6 @@ func CountCertificationPhotosNeedingApproval() int64 {
return count
}
// MaybeRevokeCertificationForEmptyGallery will delete a user's certification photo if they delete every picture from their gallery.
//
// Returns true if their certification was revoked.
func MaybeRevokeCertificationForEmptyGallery(user *User) bool {
cert, err := GetCertificationPhoto(user.ID)
if err != nil {
return false
}
// Ignore if their cert photo status is not applicable to be revoked.
if cert.Status == CertificationPhotoNeeded || cert.Status == CertificationPhotoRejected {
return false
}
if count := CountPhotos(user.ID); count == 0 {
// Revoke their cert status.
cert.Status = CertificationPhotoRejected
cert.SecondaryVerified = false
cert.AdminComment = "Your certification photo has been automatically rejected because you have deleted every photo on your gallery. " +
"To restore your certified status, please upload photos to your gallery and submit a new Certification Photo for approval."
if err := cert.Save(); err != nil {
log.Error("MaybeRevokeCertificationForEmptyGallery(%s): %s", user.Username, err)
}
// Update the user's Certified flag. Note: we freshly query the user here in case they had JUST deleted
// their default profile picture - so that we don't (re)set their old ProfilePhotoID by accident!
if user, err := GetUser(user.ID); err == nil {
user.Certified = false
if err := user.Save(); err != nil {
log.Error("MaybeRevokeCertificationForEmptyGallery(%s): saving user certified flag: %s", user.Username, err)
}
}
// Notify the site admin for visibility.
fb := &Feedback{
Intent: "report",
Subject: "A certified user has deleted all their pictures",
UserID: user.ID,
TableName: "users",
TableID: user.ID,
Message: fmt.Sprintf(
"The username **@%s** has deleted every picture in their gallery, and so their Certification Photo status has been revoked.",
user.Username,
),
}
// Save the feedback.
if err := CreateFeedback(fb); err != nil {
log.Error("Couldn't save feedback from user auto-revoking their cert photo: %s", err)
}
return true
}
return false
}
// Save photo.
func (p *CertificationPhoto) Save() error {
result := DB.Save(p)

View File

@ -13,8 +13,8 @@ import (
// Comment table - in forum threads, on profiles or photos, etc.
type Comment struct {
ID uint64 `gorm:"primaryKey"`
TableName string `gorm:"index:idx_comment_composite"`
TableID uint64 `gorm:"index:idx_comment_composite"`
TableName string `gorm:"index"`
TableID uint64 `gorm:"index"`
UserID uint64 `gorm:"index"`
User User `json:"-"`
Message string

View File

@ -1,130 +0,0 @@
package models
import (
"encoding/json"
"fmt"
"time"
"code.nonshy.com/nonshy/website/pkg/encryption"
"code.nonshy.com/nonshy/website/pkg/log"
"gorm.io/gorm/clause"
)
// DeletedUserMemory table stores security-related information, such as block lists, when a user
// deletes their account - so that when they sign up again later under the same username or email
// address, this information can be restored and other users on the site can have their block lists
// respected.
type DeletedUserMemory struct {
Username string `gorm:"uniqueIndex:idx_deleted_user_memory"`
HashedEmail string `gorm:"uniqueIndex:idx_deleted_user_memory"`
Data string
CreatedAt time.Time
UpdatedAt time.Time
}
// DeletedUserMemoryData is a JSON serializable struct for data stored in the DeletedUserMemory table.
type DeletedUserMemoryData struct {
PreviousUsername string
BlockingUserIDs []uint64
BlockedByUserIDs []uint64
}
// CreateDeletedUserMemory creates the row in the database just before a user account is deleted.
func CreateDeletedUserMemory(user *User) error {
// Get the user's blocked lists.
forward, reverse := GetAllBlockedUserIDs(user)
// Store the memory.
data := DeletedUserMemoryData{
PreviousUsername: user.Username,
BlockingUserIDs: forward,
BlockedByUserIDs: reverse,
}
bin, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("JSON marshal: %s", err)
}
// Upsert the mute.
m := &DeletedUserMemory{
Username: user.Username,
HashedEmail: string(encryption.Hash([]byte(user.Email))),
Data: string(bin),
}
res := DB.Model(&DeletedUserMemory{}).Clauses(
clause.OnConflict{
Columns: []clause.Column{
{Name: "username"},
{Name: "hashed_email"},
},
UpdateAll: true,
},
).Create(m)
return res.Error
}
// RestoreDeletedUserMemory checks for remembered data and will restore and clear the memory if found.
func RestoreDeletedUserMemory(user *User) error {
// Anything stored?
var (
m *DeletedUserMemory
hashedEmail = string(encryption.Hash([]byte(user.Email)))
err = DB.Model(&DeletedUserMemory{}).Where(
"username = ? OR hashed_email = ?",
user.Username,
hashedEmail,
).First(&m).Error
)
if err != nil {
return nil
}
// Parse the remembered payload.
var data DeletedUserMemoryData
err = json.Unmarshal([]byte(m.Data), &data)
if err != nil {
return fmt.Errorf("RestoreDeletedUserMemory: JSON unmarshal: %s", err)
}
// Bulk restore the user's block list.
blocks, err := BulkRestoreBlockedUserIDs(user, data.BlockingUserIDs, data.BlockedByUserIDs)
if err != nil {
log.Error("BulkRestoreBlockedUserIDs(%s): %s", user.Username, err)
}
// If any blocks were added, notify the admin that the user has returned - for visibility
// and to detect any mistakes.
if blocks > 0 {
fb := &Feedback{
Intent: "report",
Subject: "A deleted user has returned",
UserID: user.ID,
TableName: "users",
TableID: user.ID,
Message: fmt.Sprintf(
"The username **@%s**, who was previously deleted, has signed up a new account "+
"with the same username or e-mail address.\n\n"+
"Their previous username was **@%s** when they last deleted their account.\n\n"+
"Their block lists from when they deleted their old account have been restored:\n\n"+
"* Forward list: %d\n* Reverse list: %d",
user.Username,
data.PreviousUsername,
len(data.BlockingUserIDs),
len(data.BlockedByUserIDs),
),
}
// Save the feedback.
if err := CreateFeedback(fb); err != nil {
log.Error("Couldn't save feedback from user recovering their deleted account: %s", err)
}
}
// Delete the stored user memory.
return DB.Where(
"username = ? OR hashed_email = ?",
user.Username,
hashedEmail,
).Delete(&DeletedUserMemory{}).Error
}

View File

@ -13,12 +13,6 @@ import (
func DeleteUser(user *models.User) error {
log.Error("BEGIN DeleteUser(%d, %s)", user.ID, user.Username)
// Store the user's block lists in case they come back soon under the same email address
// or username.
if err := models.CreateDeletedUserMemory(user); err != nil {
log.Error("DeleteUser(%s): CreateDeletedUserMemory: %s", user.Username, err)
}
// Clear their history on the chat room.
go func() {
i, err := chat.EraseChatHistory(user.Username)
@ -57,7 +51,6 @@ func DeleteUser(user *models.User) error {
{"Messages", DeleteUserMessages},
{"Friends", DeleteFriends},
{"Blocks", DeleteBlocks},
{"MutedUsers", DeleteMutedUsers},
{"Feedbacks", DeleteFeedbacks},
{"Two Factor", DeleteTwoFactor},
{"Profile Fields", DeleteProfile},
@ -66,7 +59,6 @@ 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 {
@ -234,16 +226,6 @@ func DeleteBlocks(userID uint64) error {
return result.Error
}
// DeleteMutedUsers scrubs data for deleting a user.
func DeleteMutedUsers(userID uint64) error {
log.Error("DeleteUser: DeleteMutedUsers(%d)", userID)
result := models.DB.Where(
"source_user_id = ? OR target_user_id = ?",
userID, userID,
).Delete(&models.MutedUser{})
return result.Error
}
// DeleteFeedbacks scrubs data for deleting a user.
func DeleteFeedbacks(userID uint64) error {
log.Error("DeleteUser: DeleteFeedbacks(%d)", userID)
@ -424,13 +406,3 @@ 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
}

View File

@ -1,196 +0,0 @@
// 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"
"code.nonshy.com/nonshy/website/pkg/utility"
)
// 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 string
}
/**
* 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() string {
if p.Total == 0 {
return "0"
}
return utility.FormatFloatToPrecision((float64(p.Explicit)/float64(p.Total))*100, 1)
}
func (p Photo) PercentNonExplicit() string {
if p.Total == 0 {
return "0"
}
return utility.FormatFloatToPrecision((float64(p.NonExplicit)/float64(p.Total))*100, 1)
}
func (p People) PercentExplicit() string {
if p.Total == 0 {
return "0"
}
return utility.FormatFloatToPrecision((float64(p.ExplicitOptIn)/float64(p.Total))*100, 1)
}
func (p People) PercentExplicitPhoto() string {
if p.Total == 0 {
return "0"
}
return utility.FormatFloatToPrecision((float64(p.ExplicitPhoto)/float64(p.Total))*100, 1)
}
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 float64
)
if p.Total > 0 {
pct = ((float64(count) / float64(p.Total)) * 100)
}
result = append(result, MemberDemographic{
Label: age,
Count: p.ByAgeRange[age],
Percent: utility.FormatFloatToPrecision(pct, 1),
})
}
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 float64
)
if p.Total > 0 {
pct = ((float64(count) / float64(p.Total)) * 100)
}
result = append(result, MemberDemographic{
Label: gender,
Count: p.ByGender[gender],
Percent: utility.FormatFloatToPrecision(pct, 1),
})
}
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 float64
)
if p.Total > 0 {
pct = ((float64(count) / float64(p.Total)) * 100)
}
result = append(result, MemberDemographic{
Label: gender,
Count: p.ByOrientation[gender],
Percent: utility.FormatFloatToPrecision(pct, 1),
})
}
return result
}

View File

@ -1,302 +0,0 @@
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{"": 0},
ByOrientation: map[string]int64{"": 0},
}
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
WHERE users.status = 'active'
AND users.certified IS TRUE
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.
var (
totalWithAge int64 // will be the total count of users since age is required
totalWithGender int64
totalWithOrientation int64
)
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
totalWithAge += row.MetricCount
case "GenderCount":
if _, ok := result.ByGender[row.MetricValue]; !ok {
result.ByGender[row.MetricValue] = 0
}
result.ByGender[row.MetricValue] += row.MetricCount
totalWithGender += row.MetricCount
case "OrientationCount":
if _, ok := result.ByOrientation[row.MetricValue]; !ok {
result.ByOrientation[row.MetricValue] = 0
}
result.ByOrientation[row.MetricValue] += row.MetricCount
totalWithOrientation += row.MetricCount
}
}
// Gender and Orientation: pad out the "no answer" selection with the count of users
// who had no profile_fields stored in the DB at all.
result.ByOrientation[""] += (totalWithAge - totalWithOrientation)
result.ByGender[""] += (totalWithAge - totalWithGender)
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
JOIN users ON (photos.user_id = users.id)
WHERE photos.visibility = 'public'
AND photos.gallery IS TRUE
AND users.certified IS TRUE
AND users.status = 'active'
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,34 +19,30 @@ 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{
// 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},
{"MutedUser", ExportMutedUserTable},
{"Notification", ExportNotificationTable},
{"User", ExportUserTable},
{"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},
{"PrivatePhoto", ExportPrivatePhotoTable},
{"PushNotification", ExportPushNotificationTable},
{"Subscription", ExportSubscriptionTable},
{"Thread", ExportThreadTable},
{"TwoFactor", ExportTwoFactorTable},
{"UsageStatistic", ExportUsageStatisticTable},
{"User", ExportUserTable},
// Note: AdminGroup info is eager-loaded in User export
{"UserLocation", ExportUserLocationTable},
{"UserNote", ExportUserNoteTable},
{"ChangeLog", ExportChangeLogTable},
{"TwoFactor", ExportTwoFactorTable},
{"IPAddress", ExportIPAddressTable},
}
for _, item := range todo {
log.Info("Exporting data model: %s", item.Step)
@ -190,21 +186,6 @@ func ExportBlockTable(zw *zip.Writer, user *models.User) error {
return ZipJson(zw, "blocks.json", items)
}
func ExportMutedUserTable(zw *zip.Writer, user *models.User) error {
var (
items = []*models.MutedUser{}
query = models.DB.Model(&models.MutedUser{}).Where(
"source_user_id = ?",
user.ID,
).Find(&items)
)
if query.Error != nil {
return query.Error
}
return ZipJson(zw, "muted_users.json", items)
}
func ExportFeedbackTable(zw *zip.Writer, user *models.User) error {
var (
items = []*models.Feedback{}
@ -463,48 +444,3 @@ 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)
}

View File

@ -1,7 +1,6 @@
package models
import (
"sort"
"strings"
"time"
@ -12,7 +11,6 @@ import (
type Feedback struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"index"` // if logged-in user posted this
AboutUserID uint64 // associated 'about' user (e.g., owner of a reported photo)
Acknowledged bool `gorm:"index"` // admin dashboard "read" status
Intent string
Subject string
@ -47,7 +45,7 @@ func CountUnreadFeedback() int64 {
}
// PaginateFeedback
func PaginateFeedback(acknowledged bool, intent, subject string, search *Search, pager *Pagination) ([]*Feedback, error) {
func PaginateFeedback(acknowledged bool, intent string, pager *Pagination) ([]*Feedback, error) {
var (
fb = []*Feedback{}
wheres = []string{}
@ -62,23 +60,6 @@ func PaginateFeedback(acknowledged bool, intent, subject string, search *Search,
placeholders = append(placeholders, intent)
}
if subject != "" {
wheres = append(wheres, "subject = ?")
placeholders = append(placeholders, subject)
}
// Search terms.
for _, term := range search.Includes {
var ilike = "%" + strings.ToLower(term) + "%"
wheres = append(wheres, "message ILIKE ?")
placeholders = append(placeholders, ilike)
}
for _, term := range search.Excludes {
var ilike = "%" + strings.ToLower(term) + "%"
wheres = append(wheres, "message NOT ILIKE ?")
placeholders = append(placeholders, ilike)
}
query := DB.Where(
strings.Join(wheres, " AND "),
placeholders...,
@ -100,49 +81,19 @@ func PaginateFeedback(acknowledged bool, intent, subject string, search *Search,
// It returns reports where table_name=users and their user ID, or where table_name=photos and about any
// of their current photo IDs. Additionally, it will look for chat room reports which were about their
// username.
//
// The 'show' parameter applies some basic filter choices:
//
// - Blank string (default) = all reports From or About this user
// - "about" = all reports About this user (by table_name=users table_id=userID, or table_name=photos
// for any of their existing photo IDs)
// - "from" = all reports From this user (where reporting user_id is the user's ID)
// - "fuzzy" = fuzzy full text search on all reports that contain the user's username.
func PaginateFeedbackAboutUser(user *User, show string, pager *Pagination) ([]*Feedback, error) {
func PaginateFeedbackAboutUser(user *User, pager *Pagination) ([]*Feedback, error) {
var (
fb = []*Feedback{}
photoIDs, _ = user.AllPhotoIDs()
wheres = []string{}
placeholders = []interface{}{}
like = "%" + user.Username + "%"
)
// How to apply the search filters?
switch show {
case "about":
wheres = append(wheres, `
about_user_id = ? OR
(table_name = 'users' AND table_id = ?) OR
(table_name = 'photos' AND table_id IN ?)
`)
placeholders = append(placeholders, user.ID, user.ID, photoIDs)
case "from":
wheres = append(wheres, "user_id = ?")
placeholders = append(placeholders, user.ID)
case "fuzzy":
wheres = append(wheres, "message LIKE ?")
placeholders = append(placeholders, like)
default:
// Default=everything.
wheres = append(wheres, `
user_id = ? OR
about_user_id = ? OR
(table_name = 'users' AND table_id = ?) OR
(table_name = 'photos' AND table_id IN ?) OR
message LIKE ?
`)
placeholders = append(placeholders, user.ID, user.ID, user.ID, photoIDs, like)
}
wheres = append(wheres, `
(table_name = 'users' AND table_id = ?) OR
(table_name = 'photos' AND table_id IN ?)
`)
placeholders = append(placeholders, user.ID, photoIDs)
query := DB.Where(
strings.Join(wheres, " AND "),
@ -160,22 +111,6 @@ func PaginateFeedbackAboutUser(user *User, show string, pager *Pagination) ([]*F
return fb, result.Error
}
// DistinctFeedbackSubjects returns the distinct subjects on feedback & reports.
func DistinctFeedbackSubjects() []string {
var results = []string{}
query := DB.Model(&Feedback{}).
Select("DISTINCT feedbacks.subject").
Group("feedbacks.subject").
Find(&results)
if query.Error != nil {
log.Error("DistinctFeedbackSubjects: %s", query.Error)
return nil
}
sort.Strings(results)
return results
}
// CreateFeedback saves a new Feedback row to the DB.
func CreateFeedback(fb *Feedback) error {
result := DB.Create(fb)

View File

@ -140,10 +140,7 @@ func PaginateForums(user *User, categories []string, search *Search, subscribed
WHERE user_id = ?
AND forum_id = forums.id
)
OR (
forums.owner_id = ?
AND (forums.category = '' OR forums.category IS NULL)
)
OR forums.owner_id = ?
`)
placeholders = append(placeholders, user.ID, user.ID)
}

View File

@ -11,8 +11,8 @@ import (
type Like struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"index"` // who it belongs to
TableName string `gorm:"index:idx_likes_composite"`
TableID uint64 `gorm:"index:idx_likes_composite"`
TableName string `gorm:"index"`
TableID uint64 `gorm:"index"`
CreatedAt time.Time `gorm:"index"`
UpdatedAt time.Time
}

View File

@ -169,16 +169,6 @@ func HasMessageThread(a, b *User) (uint64, bool) {
return 0, false
}
// HasSentAMessage tells if the source user has sent a DM to the target user.
func HasSentAMessage(sourceUser, targetUser *User) bool {
var count int64
DB.Model(&Message{}).Where(
"source_user_id = ? AND target_user_id = ?",
sourceUser.ID, targetUser.ID,
).Count(&count)
return count > 0
}
// DeleteMessageThread removes all message history between two people.
func DeleteMessageThread(message *Message) error {
return DB.Where(

View File

@ -24,7 +24,6 @@ func AutoMigrate() {
&IPAddress{}, // ✔
&Like{}, // ✔
&Message{}, // ✔
&MutedUser{}, // ✔
&Notification{}, // ✔
&ProfileField{}, // ✔
&Photo{}, // ✔
@ -32,17 +31,15 @@ func AutoMigrate() {
&Poll{}, // vacuum script cleans up orphaned polls
&PrivatePhoto{}, // ✔
&PushNotification{}, // ✔
&Subscription{}, // ✔
&Thread{}, // ✔
&TwoFactor{}, // ✔
&UsageStatistic{}, // ✔
&Subscription{}, // ✔
&User{}, // ✔
&UserLocation{}, // ✔
&UserNote{}, // ✔
// Non-user or persistent data.
&AdminScope{},
&DeletedUserMemory{},
&Forum{},
// Vendor/misc data.

View File

@ -1,103 +0,0 @@
package models
import (
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// MutedUser table, for users to mute one another's Site Gallery photos and similar.
type MutedUser struct {
ID uint64 `gorm:"primaryKey"`
SourceUserID uint64 `gorm:"uniqueIndex:idx_muted_user"`
TargetUserID uint64 `gorm:"uniqueIndex:idx_muted_user"`
Context MutedUserContext `gorm:"uniqueIndex:idx_muted_user"`
CreatedAt time.Time
UpdatedAt time.Time
}
type MutedUserContext string
// Context options for MutedUser to specify what is being muted.
const (
MutedUserContextSiteGallery MutedUserContext = "site_gallery" // hide a user's photos from the Site Gallery.
)
// IsValidMuteUserContext validates acceptable options for muting users.
func IsValidMuteUserContext(ctx MutedUserContext) bool {
return ctx == MutedUserContextSiteGallery
}
// AddMutedUser is sourceUserId adding targetUserId to their mute list under the given context.
func AddMutedUser(sourceUserID, targetUserID uint64, ctx MutedUserContext) error {
m := &MutedUser{
SourceUserID: sourceUserID,
TargetUserID: targetUserID,
Context: ctx,
}
// Upsert the mute.
res := DB.Model(&MutedUser{}).Clauses(
clause.OnConflict{
Columns: []clause.Column{
{Name: "source_user_id"},
{Name: "target_user_id"},
{Name: "context"},
},
DoNothing: true,
},
).Create(m)
return res.Error
}
// MutedUserIDs returns all user IDs Muted by the user.
func MutedUserIDs(user *User, context MutedUserContext) []uint64 {
var (
ms = []*MutedUser{}
userIDs = []uint64{}
)
DB.Where("source_user_id = ? AND context = ?", user.ID, context).Find(&ms)
for _, row := range ms {
userIDs = append(userIDs, row.TargetUserID)
}
return userIDs
}
// PaginateMuteList views a user's mute lists.
func PaginateMuteList(user *User, pager *Pagination) ([]*User, error) {
// We paginate over the MutedUser table.
var (
ms = []*MutedUser{}
userIDs = []uint64{}
query *gorm.DB
)
query = DB.Where(
"source_user_id = ?",
user.ID,
)
query = query.Order(pager.Sort)
query.Model(&MutedUser{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&ms)
if result.Error != nil {
return nil, result.Error
}
// Now of these users get their User objects.
for _, b := range ms {
userIDs = append(userIDs, b.TargetUserID)
}
return GetUsers(user, userIDs)
}
// RemoveMutedUser clears the muted user row.
func RemoveMutedUser(sourceUserID, targetUserID uint64, ctx MutedUserContext) error {
result := DB.Where(
"source_user_id = ? AND target_user_id = ? AND context = ?",
sourceUserID, targetUserID, ctx,
).Delete(&MutedUser{})
return result.Error
}

View File

@ -46,7 +46,6 @@ const (
NotificationPrivatePhoto NotificationType = "private_photo" // private photo grants
NotificationNewPhoto NotificationType = "new_photo"
NotificationForumModerator NotificationType = "forum_moderator" // appointed as a forum moderator
NotificationExplicitPhoto NotificationType = "explicit_photo" // user photo was flagged explicit
NotificationCustom NotificationType = "custom" // custom message pushed
)

View File

@ -85,13 +85,7 @@ func (nf NotificationFilter) Query() (where string, placeholders []interface{},
types = append(types, NotificationPrivatePhoto)
}
if nf.Misc {
types = append(types,
NotificationFriendApproved,
NotificationCertApproved,
NotificationCertRejected,
NotificationExplicitPhoto,
NotificationCustom,
)
types = append(types, NotificationFriendApproved, NotificationCertApproved, NotificationCertRejected, NotificationCustom)
}
return "type IN ?", types, true

View File

@ -8,7 +8,6 @@ import (
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/redis"
"gorm.io/gorm"
)
@ -22,14 +21,10 @@ type Photo struct {
Caption string
AltText string
Flagged bool // photo has been reported by the community
AdminLabel string // admin label(s) on this photo (e.g. verified non-explicit)
Visibility PhotoVisibility `gorm:"index"`
Gallery bool `gorm:"index"` // photo appears in the public gallery (if public)
Explicit bool `gorm:"index"` // is an explicit photo
Pinned bool `gorm:"index"` // user pins it to the front of their gallery
LikeCount int64 `gorm:"index"` // cache of 'likes' count
CommentCount int64 `gorm:"index"` // cache of comments count
Views uint64 `gorm:"index"` // view count
CreatedAt time.Time `gorm:"index"`
UpdatedAt time.Time
}
@ -108,71 +103,6 @@ func GetPhotos(IDs []uint64) (map[uint64]*Photo, error) {
return mp, result.Error
}
// CanBeEditedBy checks whether a photo can be edited by the current user.
//
// Admins with PhotoModerator scope can always edit.
func (p *Photo) CanBeEditedBy(currentUser *User) bool {
if currentUser.HasAdminScope(config.ScopePhotoModerator) {
return true
}
return p.UserID == currentUser.ID
}
// CanBeSeenBy checks whether a photo can be seen by the current user.
//
// An admin user with omni photo view permission can always see the photo.
//
// Note: this function incurs several DB queries to look up the photo's owner and block lists.
func (p *Photo) CanBeSeenBy(currentUser *User) (bool, error) {
// Admins with photo moderator ability can always see.
if currentUser.HasAdminScope(config.ScopePhotoModerator) {
return true, nil
}
return p.ShouldBeSeenBy(currentUser)
}
// ShouldBeSeenBy checks whether a photo should be seen by the current user.
//
// Even if the current user is an admin with photo moderator ability, this function will return
// whether the admin 'should' be able to see if not for their admin status. Example: a private
// photo may be shown to the admin so they can moderate it, but they shouldn't be able to "like"
// it or mark it as "viewed."
//
// Note: this function incurs several DB queries to look up the photo's owner and block lists.
func (p *Photo) ShouldBeSeenBy(currentUser *User) (bool, error) {
// Find the photo's owner.
user, err := GetUser(p.UserID)
if err != nil {
return false, err
}
var isOwnPhoto = currentUser.ID == user.ID
// Is either one blocking?
if IsBlocking(currentUser.ID, user.ID) {
return false, errors.New("is blocking")
}
// Is this user private and we're not friends?
var (
areFriends = AreFriends(user.ID, currentUser.ID)
isPrivate = user.Visibility == UserVisibilityPrivate && !areFriends
)
if isPrivate && !isOwnPhoto {
return false, errors.New("user is private and we aren't friends")
}
// Is this a private photo and are we allowed to see?
isGranted := IsPrivateUnlocked(user.ID, currentUser.ID)
if p.Visibility == PhotoPrivate && !isGranted && !isOwnPhoto {
return false, errors.New("photo is private")
}
return true, nil
}
// UserGallery configuration for filtering gallery pages.
type UserGallery struct {
Explicit string // "", "true", "false"
@ -222,35 +152,6 @@ func PaginateUserPhotos(userID uint64, conf UserGallery, pager *Pagination) ([]*
return p, result.Error
}
// View a photo, incrementing its Views count but not its UpdatedAt.
// Debounced with a Redis key.
func (p *Photo) View(user *User) error {
// The owner of the photo does not count views.
if p.UserID == user.ID {
return nil
}
// Should the viewer be able to see this, regardless of their admin ability?
if ok, err := p.ShouldBeSeenBy(user); !ok {
return err
}
// Debounce this.
var redisKey = fmt.Sprintf(config.PhotoViewDebounceRedisKey, user.ID, p.ID)
if redis.Exists(redisKey) {
return nil
}
redis.Set(redisKey, nil, config.PhotoViewDebounceCooldown)
return DB.Model(&Photo{}).Where(
"id = ?",
p.ID,
).Updates(map[string]interface{}{
"views": p.Views + 1,
"updated_at": p.UpdatedAt,
}).Error
}
// CountPhotos returns the total number of photos on a user's account.
func CountPhotos(userID uint64) int64 {
var count int64
@ -286,69 +187,6 @@ func GetOrphanedPhotos() ([]*Photo, int64, error) {
return ps, count, res.Error
}
// HasAdminLabelNonExplicit checks if the non-explicit admin label is applied to this photo.
func (p *Photo) HasAdminLabelNonExplicit() bool {
return config.HasAdminLabel(config.AdminLabelPhotoNonExplicit, p.AdminLabel)
}
// HasAdminLabelForceExplicit checks if the force-explicit admin label is applied to this photo.
func (p *Photo) HasAdminLabelForceExplicit() bool {
return config.HasAdminLabel(config.AdminLabelPhotoForceExplicit, p.AdminLabel)
}
// HasAdminLabel checks if the photo has an admin label (for convenient front-end access on the Edit page).
func (p *Photo) HasAdminLabel(label string) bool {
return config.HasAdminLabel(label, p.AdminLabel)
}
// PhotoMap helps map a set of users to look up by ID.
type PhotoMap map[uint64]*Photo
// MapPhotos looks up a set of photos IDs in bulk and returns a PhotoMap suitable for templates.
func MapPhotos(photoIDs []uint64) (PhotoMap, error) {
var (
photoMap = PhotoMap{}
set = map[uint64]interface{}{}
distinct = []uint64{}
)
// Uniqueify the IDs.
for _, uid := range photoIDs {
if _, ok := set[uid]; ok {
continue
}
set[uid] = nil
distinct = append(distinct, uid)
}
var (
photos = []*Photo{}
result = DB.Model(&Photo{}).Where("id IN ?", distinct).Find(&photos)
)
if result.Error == nil {
for _, row := range photos {
photoMap[row.ID] = row
}
}
return photoMap, result.Error
}
// Has a photo ID in the map?
func (pm PhotoMap) Has(id uint64) bool {
_, ok := pm[id]
return ok
}
// Get a photo from the PhotoMap.
func (pm PhotoMap) Get(id uint64) *Photo {
if photo, ok := pm[id]; ok {
return photo
}
return nil
}
/*
IsSiteGalleryThrottled returns whether the user is throttled from marking additional pictures for the Site Gallery.
@ -451,11 +289,6 @@ func CountPhotosICanSee(user *User, viewer *User) int64 {
// MapPhotoCounts returns a mapping of user ID to the CountPhotos()-equivalent result for each.
// It's used on the member directory to show photo counts on each user card.
func MapPhotoCounts(users []*User) PhotoCountMap {
return MapPhotoCountsByVisibility(users, PhotoPublic)
}
// MapPhotoCountsByVisibility returns a mapping of user ID to the CountPhotos()-equivalent result for each.
func MapPhotoCountsByVisibility(users []*User, visibility PhotoVisibility) PhotoCountMap {
var (
userIDs = []uint64{}
result = PhotoCountMap{}
@ -476,7 +309,7 @@ func MapPhotoCountsByVisibility(users []*User, visibility PhotoVisibility) Photo
).Select(
"user_id, count(id) AS photo_count",
).Where(
"user_id IN ? AND visibility = ?", userIDs, visibility,
"user_id IN ? AND visibility = ?", userIDs, PhotoPublic,
).Group("user_id").Scan(&groups); res.Error != nil {
log.Error("CountPhotosForUsers: %s", res.Error)
}
@ -595,21 +428,16 @@ func CountExplicitPhotos(userID uint64, visibility []PhotoVisibility) (int64, er
// CountPublicPhotos returns the number of public photos on a user's page.
func CountPublicPhotos(userID uint64) int64 {
return CountUserPhotosByVisibility(userID, PhotoPublic)
}
// CountUserPhotosByVisibility returns the number of a user's photos by visibility.
func CountUserPhotosByVisibility(userID uint64, visibility PhotoVisibility) int64 {
query := DB.Where(
"user_id = ? AND visibility = ?",
userID,
visibility,
PhotoPublic,
)
var count int64
result := query.Model(&Photo{}).Count(&count)
if result.Error != nil {
log.Error("CountUserPhotosByVisibility(%d, %s): %s", userID, visibility, result.Error)
log.Error("CountPublicPhotos(%d): %s", userID, result.Error)
}
return count
}
@ -644,13 +472,6 @@ func (u *User) DistinctPhotoTypes() (result map[PhotoVisibility]struct{}) {
return
}
// FlushCaches clears any cached attributes (such as distinct photo types) for the user.
func (u *User) FlushCaches() {
u.cachePhotoTypes = nil
u.cacheBlockedUserIDs = nil
u.cachePhotoIDs = nil
}
// Gallery config for the main Gallery paginator.
type Gallery struct {
Explicit string // Explicit filter
@ -682,7 +503,6 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
explicitOK = user.Explicit // User opted-in for explicit content
blocklist = BlockedUserIDs(user)
mutelist = MutedUserIDs(user, MutedUserContextSiteGallery)
privateUserIDs = PrivateGrantedUserIDs(userID)
privateUserIDsAreFriends = PrivateGrantedUserIDsAreFriends(user)
wheres = []string{}
@ -725,9 +545,9 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
// Shy users can only see their Friends photos (public or friends visibility)
// and any Private photos to whom they were granted access.
visOrs = append(visOrs,
fmt.Sprintf("(photos.user_id IN %s AND photos.visibility IN ?)", friendsQuery),
"(photos.user_id IN ? AND photos.visibility IN ?)",
"photos.user_id = ?",
fmt.Sprintf("(user_id IN %s AND visibility IN ?)", friendsQuery),
"(user_id IN ? AND visibility IN ?)",
"user_id = ?",
)
visPlaceholders = append(visPlaceholders,
photosFriends,
@ -737,23 +557,23 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
} else if friendsOnly {
// User wants to see only self and friends photos.
visOrs = append(visOrs,
fmt.Sprintf("(photos.user_id IN %s AND photos.visibility IN ?)", friendsQuery),
"photos.user_id = ?",
fmt.Sprintf("(user_id IN %s AND visibility IN ?)", friendsQuery),
"user_id = ?",
)
visPlaceholders = append(visPlaceholders, photosFriends, userID)
// If their friends granted private photos, include those too.
if len(privateUserIDsAreFriends) > 0 {
visOrs = append(visOrs, "(photos.user_id IN ? AND photos.visibility IN ?)")
visOrs = append(visOrs, "(user_id IN ? AND visibility IN ?)")
visPlaceholders = append(visPlaceholders, privateUserIDsAreFriends, photosPrivate)
}
} else {
// You can see friends' Friend photos but only public for non-friends.
visOrs = append(visOrs,
fmt.Sprintf("(photos.user_id IN %s AND photos.visibility IN ?)", friendsQuery),
"(photos.user_id IN ? AND photos.visibility IN ?)",
fmt.Sprintf("(photos.user_id NOT IN %s AND photos.visibility IN ?)", friendsQuery),
"photos.user_id = ?",
fmt.Sprintf("(user_id IN %s AND visibility IN ?)", friendsQuery),
"(user_id IN ? AND visibility IN ?)",
fmt.Sprintf("(user_id NOT IN %s AND visibility IN ?)", friendsQuery),
"user_id = ?",
)
visPlaceholders = append(placeholders,
photosFriends,
@ -768,7 +588,7 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
placeholders = append(placeholders, visPlaceholders...)
// Gallery photos only.
wheres = append(wheres, "photos.gallery = ?")
wheres = append(wheres, "gallery = ?")
placeholders = append(placeholders, true)
// Filter by photos the user has liked.
@ -777,9 +597,9 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
EXISTS (
SELECT 1
FROM likes
WHERE likes.user_id = ?
AND likes.table_name = 'photos'
AND likes.table_id = photos.id
WHERE user_id = ?
AND table_name = 'photos'
AND table_id = photos.id
)
`)
placeholders = append(placeholders, user.ID)
@ -787,45 +607,39 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
// Filter blocked users.
if len(blocklist) > 0 {
wheres = append(wheres, "photos.user_id NOT IN ?")
wheres = append(wheres, "user_id NOT IN ?")
placeholders = append(placeholders, blocklist)
}
// Filter Site Gallery muted users.
if len(mutelist) > 0 {
wheres = append(wheres, "photos.user_id NOT IN ?")
placeholders = append(placeholders, mutelist)
}
// Non-explicit pics unless the user opted in. Allow explicit filter setting to override.
if filterExplicit != "" {
wheres = append(wheres, "photos.explicit = ?")
wheres = append(wheres, "explicit = ?")
placeholders = append(placeholders, filterExplicit == "true")
} else if !explicitOK {
wheres = append(wheres, "photos.explicit = ?")
wheres = append(wheres, "explicit = ?")
placeholders = append(placeholders, false)
}
// Is the user furthermore clamping the visibility filter?
if filterVisibility != "" {
wheres = append(wheres, "photos.visibility = ?")
wheres = append(wheres, "visibility = ?")
placeholders = append(placeholders, filterVisibility)
}
// Only certified (and not banned) user photos.
if conf.Uncertified {
wheres = append(wheres,
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND users.certified IS NOT true AND users.status='active')",
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND certified IS NOT true AND status='active')",
)
} else {
wheres = append(wheres,
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND users.certified = true AND users.status='active')",
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND certified = true AND status='active')",
)
}
// Exclude private users' photos.
wheres = append(wheres,
"NOT EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND photos.visibility = 'private')",
"NOT EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND visibility = 'private')",
)
// Admin view: get ALL PHOTOS on the site, period.
@ -834,14 +648,14 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
// Admin may filter too.
if filterVisibility != "" {
query = query.Where("photos.visibility = ?", filterVisibility)
query = query.Where("visibility = ?", filterVisibility)
}
if filterExplicit != "" {
query = query.Where("photos.explicit = ?", filterExplicit == "true")
query = query.Where("explicit = ?", filterExplicit == "true")
}
if conf.Uncertified {
query = query.Where(
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND users.certified IS NOT true AND users.status='active')",
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND certified IS NOT true AND status='active')",
)
}
} else {
@ -852,32 +666,12 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
}
query = query.Order(pager.Sort)
query.Model(&Photo{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&p)
return p, result.Error
}
// UpdatePhotoCachedCounts will refresh the cached like/comment count on the photos table.
func UpdatePhotoCachedCounts(photoID uint64) error {
res := DB.Exec(`
UPDATE photos
SET like_count = (
SELECT count(id)
FROM likes
WHERE table_name='photos'
AND table_id=photos.id
),
comment_count = (
SELECT count(id)
FROM comments
WHERE table_name='photos'
AND table_id=photos.id
)
WHERE photos.id = ?;
`, photoID)
return res.Error
}
// Save photo.
func (p *Photo) Save() error {
result := DB.Save(p)

View File

@ -1,7 +1,6 @@
package models
import (
"errors"
"fmt"
"strings"
"time"
@ -126,43 +125,6 @@ func (u *User) AllPhotoIDs() ([]uint64, error) {
return photoIDs, nil
}
/*
ShouldShowPrivateUnlockPrompt determines whether the current user should be shown a prompt, when viewing
the other user's gallery, to unlock their private photos for that user.
This function verifies that the source user actually has a private photo to share, and that the target
user doesn't have a privacy setting enabled that should block the private photo unlock request.
*/
func ShouldShowPrivateUnlockPrompt(sourceUser, targetUser *User) (bool, error) {
// If the current user doesn't even have a private photo to share, no prompt.
if CountUserPhotosByVisibility(sourceUser.ID, PhotoPrivate) == 0 {
return false, errors.New("you do not currently have a private photo on your gallery to share")
}
// Does the target user have a privacy setting enabled?
if pp := targetUser.GetProfileField("private_photo_gate"); pp != "" {
areFriends := AreFriends(sourceUser.ID, targetUser.ID)
switch pp {
case "nobody":
return false, errors.New("they decline all private photo sharing")
case "friends":
if areFriends {
return true, nil
}
return false, errors.New("they are only accepting private photos from their friends")
case "messaged":
if areFriends || HasSentAMessage(targetUser, sourceUser) {
return true, nil
}
return false, errors.New("they are only accepting private photos from their friends or from people they have sent a DM to")
}
}
return true, nil
}
// IsPrivateUnlocked quickly sees if sourceUserID has unlocked private photos for targetUserID to see.
func IsPrivateUnlocked(sourceUserID, targetUserID uint64) bool {
pb := &PrivatePhoto{}

View File

@ -1,105 +0,0 @@
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,7 +6,6 @@ import (
"errors"
"fmt"
"strings"
"sync"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
@ -46,9 +45,6 @@ type User struct {
cachePhotoTypes map[PhotoVisibility]struct{}
cacheBlockedUserIDs []uint64
cachePhotoIDs []uint64
// Feature mutexes.
muStatistic sync.Mutex
}
type UserVisibility string
@ -230,22 +226,6 @@ 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
}
// IsShyFrom tells whether the user is shy from the perspective of the other user.
//
// That is, depending on our profile visibility and friendship status.
@ -291,8 +271,7 @@ func (u *User) CanBeSeenBy(viewer *User) error {
// UserSearch config.
type UserSearch struct {
Username string // fuzzy search by name or username
InUsername []string // exact set of usernames (e.g. On Chat)
Username string
Gender string
Orientation string
MaritalStatus string
@ -374,11 +353,6 @@ func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, er
placeholders = append(placeholders, ilike, ilike)
}
if len(search.InUsername) > 0 {
wheres = append(wheres, "users.username IN ?")
placeholders = append(placeholders, search.InUsername)
}
if search.Gender != "" {
wheres = append(wheres, `
EXISTS (
@ -616,33 +590,6 @@ func MapAdminUsers(user *User) (UserMap, error) {
return MapUsers(user, userIDs)
}
// CountBlockedAdminUsers returns a count of how many admin users the current user has blocked, out of how many total.
func CountBlockedAdminUsers(user *User) (count, total int64) {
// Count the blocked admins.
DB.Model(&User{}).Select(
"count(users.id) AS cnt",
).Joins(
"JOIN blocks ON (blocks.target_user_id = users.id)",
).Where(
"blocks.source_user_id = ? AND users.is_admin IS TRUE",
user.ID,
).Count(&count)
// And the total number of available admins.
total = CountAdminUsers()
return
}
// CountAdminUsers returns a count of how many admin users exist on the site.
func CountAdminUsers() (count int64) {
DB.Model(&User{}).Select(
"count(id) AS cnt",
).Where(
"users.is_admin IS TRUE",
).Count(&count)
return
}
// Has a user ID in the map?
func (um UserMap) Has(id uint64) bool {
_, ok := um[id]
@ -707,6 +654,28 @@ func (u *User) NameOrUsername() string {
}
}
// VisibleAvatarURL returns a URL to the user's avatar taking into account
// their relationship with the current user. For example, if the avatar is
// friends-only and the current user can't see it, returns the path to the
// yellow placeholder avatar instead.
//
// Expects that UserRelationships are available on the user.
func (u *User) VisibleAvatarURL(currentUser *User) string {
canSee, visibility := u.CanSeeProfilePicture(currentUser)
if canSee {
return config.PhotoWebPath + "/" + u.ProfilePhoto.CroppedFilename
}
switch visibility {
case PhotoPrivate:
return "/static/img/shy-private.png"
case PhotoFriends:
return "/static/img/shy-friends.png"
}
return "/static/img/shy.png"
}
// CanSeeProfilePicture returns whether the current user can see the user's profile picture.
//
// Returns a boolean (false if currentUser can't see) and the Visibility setting of the profile photo.
@ -791,15 +760,6 @@ func (u *User) SetProfileField(name, value string) {
}
}
// DeleteProfileField removes a stored profile field.
func (u *User) DeleteProfileField(name string) error {
res := DB.Exec(
"DELETE FROM profile_fields WHERE user_id=? AND name=?",
u.ID, name,
)
return res.Error
}
// GetProfileField returns the value of a profile field or blank string.
func (u *User) GetProfileField(name string) string {
for _, field := range u.ProfileField {

View File

@ -42,8 +42,6 @@ func SetUserRelationships(currentUser *User, users []*User) error {
// Inject the UserRelationships.
for _, u := range users {
u.UserRelationship.Computed = true
if u.ID == currentUser.ID {
// Current user - set both bools to true - you can always see your own profile pic.
u.UserRelationship.IsFriend = true

View File

@ -1,118 +0,0 @@
package photo
import (
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/encryption"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/utility"
"github.com/golang-jwt/jwt/v4"
)
// VisibleAvatarURL returns the visible URL image to a user's square profile picture, from the point of view of the currentUser.
func VisibleAvatarURL(user, currentUser *models.User) string {
canSee, visibility := user.CanSeeProfilePicture(currentUser)
if canSee {
return SignedPublicAvatarURL(user.ProfilePhoto.CroppedFilename)
}
switch visibility {
case models.PhotoPrivate:
return "/static/img/shy-private.png"
case models.PhotoFriends:
return "/static/img/shy-friends.png"
}
return "/static/img/shy.png"
}
// ReSignPhotoLinks will search a blob of text for photo gallery links ("/static/photos/*") and re-sign
// their JWT security tokens.
func ReSignPhotoLinks(currentUser *models.User, text string) string {
var matches = config.PhotoURLRegexp.FindAllStringSubmatch(text, -1)
for _, m := range matches {
var (
origString = m[0]
url = m[1]
filename string
)
log.Error("ReSignPhotoLinks: got [%s] url [%s]", origString, url)
// Trim the /static/photos/ prefix off to get the URL down to its base filename.
filename = strings.Split(url, "?")[0]
filename = strings.TrimPrefix(filename, config.PhotoWebPath)
filename = strings.TrimPrefix(filename, "/")
// Sign the URL and replace the original.
signed := SignedPhotoURL(currentUser, filename)
text = strings.ReplaceAll(text, origString, signed)
}
return text
}
// SignedPhotoURL returns a URL path to a photo's filename, signed for the current user only.
func SignedPhotoURL(user *models.User, filename string) string {
return createSignedPhotoURL(user.ID, user.Username, filename, false)
}
// SignedPublicAvatarURL returns a signed URL for a user's public square avatar image, which has
// a much more generous JWT expiration lifetime on it.
//
// The primary use case is for the chat room: users are sent into chat with their avatar URL,
// and it must be viewable to all users for a long time.
func SignedPublicAvatarURL(filename string) string {
return createSignedPhotoURL(0, "@", filename, true)
}
// SignedPhotoClaims are a JWT claims object used to sign and authenticate image (direct .jpg) links.
type SignedPhotoClaims struct {
FilenameHash string `json:"f"` // Short hash of the Filename being signed.
Anyone bool `json:"a,omitempty"` // Non-authenticated signature (e.g. public sq avatar URLs)
// Standard claims. Notes:
// .Subject = username
jwt.RegisteredClaims
}
// FilenameHash returns a 'short' hash of the filename, for encoding in the SignedPhotoClaims.
//
// The hash is a truncated SHA256 hash as a basic validation measure against one JWT token being
// used to reveal an unrelated picture.
func FilenameHash(filename string) string {
return encryption.Hash([]byte(filename))[:6]
}
// Common function to create a signed photo URL with an expiration.
func createSignedPhotoURL(userID uint64, username string, filename string, anyone bool) string {
// Claims expire on the 10th of next month.
var (
expiresAt = utility.NextMonth(time.Now(), 10)
claims = SignedPhotoClaims{
FilenameHash: FilenameHash(filename),
Anyone: anyone,
RegisteredClaims: encryption.StandardClaims(userID, username, expiresAt),
}
)
// Lock the date stamps for a consistent JWT value for caching.
claims.IssuedAt = nil
claims.NotBefore = nil
log.Debug("createSignedPhotoURL(%s): %+v", filename, claims)
token, err := encryption.SignClaims(claims, []byte(config.Current.SignedPhoto.JWTSecret))
if err != nil {
log.Error("PhotoURL: SignClaims: %s", err)
}
// JWT query string to append?
if token != "" {
token = "?jwt=" + token
}
return URLPath(filename) + token
}

View File

@ -17,7 +17,6 @@ import (
"code.nonshy.com/nonshy/website/pkg/controller/htmx"
"code.nonshy.com/nonshy/website/pkg/controller/inbox"
"code.nonshy.com/nonshy/website/pkg/controller/index"
"code.nonshy.com/nonshy/website/pkg/controller/mutelist"
"code.nonshy.com/nonshy/website/pkg/controller/photo"
"code.nonshy.com/nonshy/website/pkg/controller/poll"
"code.nonshy.com/nonshy/website/pkg/middleware"
@ -35,10 +34,8 @@ 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 /tos/photos", index.StaticTemplate("tos_photos.html")())
mux.HandleFunc("GET /privacy", index.StaticTemplate("privacy.html")())
mux.HandleFunc("/contact", index.Contact())
mux.HandleFunc("/login", account.Login())
@ -65,7 +62,6 @@ func New() http.Handler {
mux.Handle("GET /photo/view", middleware.LoginRequired(photo.View()))
mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit()))
mux.Handle("/photo/delete", middleware.LoginRequired(photo.Delete()))
mux.Handle("/photo/batch-edit", middleware.LoginRequired(photo.BatchEdit()))
mux.Handle("/photo/certification", middleware.LoginRequired(photo.Certification()))
mux.Handle("GET /photo/private", middleware.LoginRequired(photo.Private()))
mux.Handle("/photo/private/share", middleware.LoginRequired(photo.Share()))
@ -79,9 +75,6 @@ func New() http.Handler {
mux.Handle("POST /users/block", middleware.LoginRequired(block.BlockUser()))
mux.Handle("GET /users/blocked", middleware.LoginRequired(block.Blocked()))
mux.Handle("GET /users/blocklist/add", middleware.LoginRequired(block.AddUser()))
mux.Handle("GET /users/muted", middleware.LoginRequired(mutelist.MuteList()))
mux.Handle("GET /users/mutelist/add", middleware.LoginRequired(mutelist.AddUser()))
mux.Handle("POST /users/mutelist/add", middleware.LoginRequired(mutelist.MuteUser()))
mux.Handle("/comments", middleware.LoginRequired(comment.PostComment()))
mux.Handle("GET /comments/subscription", middleware.LoginRequired(comment.Subscription()))
mux.Handle("GET /admin/unimpersonate", middleware.LoginRequired(admin.Unimpersonate()))
@ -118,7 +111,6 @@ func New() http.Handler {
// JSON API endpoints.
mux.HandleFunc("GET /v1/version", api.Version())
mux.HandleFunc("GET /v1/auth/static", api.PhotoSignAuth())
mux.HandleFunc("GET /v1/users/me", api.LoginOK())
mux.HandleFunc("POST /v1/users/check-username", api.UsernameCheck())
mux.HandleFunc("GET /v1/web-push/vapid-public-key", webpush.VAPIDPublicKey)
@ -126,7 +118,6 @@ func New() http.Handler {
mux.Handle("GET /v1/web-push/unregister", middleware.LoginRequired(webpush.UnregisterAll()))
mux.Handle("POST /v1/likes", middleware.LoginRequired(api.Likes()))
mux.Handle("GET /v1/likes/users", middleware.LoginRequired(api.WhoLikes()))
mux.Handle("POST /v1/photo/{photo_id}/view", middleware.LoginRequired(api.ViewPhoto()))
mux.Handle("POST /v1/notifications/read", middleware.LoginRequired(api.ReadNotification()))
mux.Handle("POST /v1/notifications/delete", middleware.LoginRequired(api.ClearNotification()))
mux.Handle("POST /v1/photos/mark-explicit", middleware.LoginRequired(api.MarkPhotoExplicit()))

View File

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

View File

@ -35,18 +35,12 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
"FormatNumberCommas": FormatNumberCommas(),
"ComputeAge": utility.Age,
"Split": strings.Split,
"NewHashMap": NewHashMap,
"ToMarkdown": ToMarkdown,
"DeMarkify": markdown.DeMarkify,
"ToJSON": ToJSON,
"ToHTML": ToHTML,
"ToString": func(v interface{}) string {
return fmt.Sprintf("%v", v)
},
"PhotoURL": PhotoURL(r),
"VisibleAvatarURL": photo.VisibleAvatarURL,
"Now": time.Now,
"RunTime": RunTime,
"PhotoURL": photo.URLPath,
"Now": time.Now,
"RunTime": RunTime,
"PrettyTitle": func() template.HTML {
return template.HTML(fmt.Sprintf(
`<strong style="color: #0077FF">non</strong>` +
@ -76,14 +70,6 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
// Get a description for an admin scope (e.g. for transparency page).
"AdminScopeDescription": config.AdminScopeDescription,
// "ReSignPhotoLinks": photo.ReSignPhotoLinks,
"ReSignPhotoLinks": func(s template.HTML) template.HTML {
if currentUser, err := session.CurrentUser(r); err == nil {
return template.HTML(photo.ReSignPhotoLinks(currentUser, string(s)))
}
return s
},
}
}
@ -112,19 +98,6 @@ func RunTime(r *http.Request) string {
return "ERROR"
}
// PhotoURL returns a URL path to photos.
func PhotoURL(r *http.Request) func(filename string) string {
return func(filename string) string {
// Get the current user to sign a JWT token.
var token string
if currentUser, err := session.CurrentUser(r); err == nil {
return photo.SignedPhotoURL(currentUser, filename)
}
return photo.URLPath(filename) + token
}
}
// BlurExplicit returns true if the current user has the blur_explicit setting on and the given Photo is Explicit.
func BlurExplicit(r *http.Request) func(*models.Photo) bool {
return func(photo *models.Photo) bool {
@ -265,30 +238,6 @@ func SubtractInt64(a, b int64) int64 {
return a - b
}
// NewHashMap creates a key/value dict on the fly for Go templates.
//
// Use it like: {{$Vars := NewHashMap "username" .CurrentUser.Username "photoID" .Photo.ID}}
//
// It is useful for calling Go subtemplates that need custom parameters, e.g. a
// mixin from current scope with other variables.
func NewHashMap(upsert ...interface{}) map[string]interface{} {
// Map the positional arguments into a dictionary.
var params = map[string]interface{}{}
for i := 0; i < len(upsert); i += 2 {
var (
key = fmt.Sprintf("%v", upsert[i])
value interface{}
)
if len(upsert) > i {
value = upsert[i+1]
}
params[key] = value
}
return params
}
// UrlEncode escapes a series of values (joined with no delimiter)
func UrlEncode(values ...interface{}) string {
var result string

View File

@ -162,7 +162,6 @@ func (t *Template) Reload() error {
// Base template layout.
var baseTemplates = []string{
config.TemplatePath + "/base.html",
config.TemplatePath + "/partials/alert_modal.html",
config.TemplatePath + "/partials/user_avatar.html",
config.TemplatePath + "/partials/like_modal.html",
config.TemplatePath + "/partials/right_click.html",

View File

@ -1,29 +0,0 @@
package utility
import "code.nonshy.com/nonshy/website/pkg/config"
// StringInOptions constrains a string value (posted by the user) to only be one
// of the available values in the Option list enum. Returns the string if OK, or
// else the default string.
func StringInOptions(v string, options []config.Option, orDefault string) string {
for _, option := range options {
if v == option.Value {
return v
}
}
return orDefault
}
// StringInOptGroup constrains a string value (posted by the user) to only be one
// of the available values in the Option list enum. Returns the string if OK, or
// else the default string.
func StringInOptGroup(v string, options []config.OptGroup, orDefault string) string {
for _, group := range options {
for _, option := range group.Options {
if v == option.Value {
return v
}
}
}
return orDefault
}

View File

@ -6,17 +6,6 @@ import (
"strings"
)
// FormatFloatToPrecision will trim a floating point number to at most a number of decimals of precision.
//
// If the precision is ".0" the decimal place will be stripped entirely.
func FormatFloatToPrecision(v float64, prec int) string {
s := strconv.FormatFloat(v, 'f', prec, 64)
if strings.HasSuffix(s, ".0") {
return strings.Split(s, ".")[0]
}
return s
}
// FormatNumberShort compresses a number into as short a string as possible (e.g. "1.2K" when it gets into the thousands).
func FormatNumberShort(value int64) string {
// Under 1,000?
@ -31,13 +20,21 @@ func FormatNumberShort(value int64) string {
billions = float64(millions) / 1000
)
formatFloat := func(v float64) string {
s := strconv.FormatFloat(v, 'f', 1, 64)
if strings.HasSuffix(s, ".0") {
return strings.Split(s, ".")[0]
}
return s
}
if thousands < 1000 {
return fmt.Sprintf("%sK", FormatFloatToPrecision(thousands, 1))
return fmt.Sprintf("%sK", formatFloat(thousands))
}
if millions < 1000 {
return fmt.Sprintf("%sM", FormatFloatToPrecision(millions, 1))
return fmt.Sprintf("%sM", formatFloat(millions))
}
return fmt.Sprintf("%sB", FormatFloatToPrecision(billions, 1))
return fmt.Sprintf("%sB", formatFloat(billions))
}

View File

@ -7,22 +7,6 @@ import (
"time"
)
// NextMonth takes an input time (usually time.Now) and will return the next month on the given day.
//
// Example, NextMonth from any time in April should return e.g. May 10th.
func NextMonth(now time.Time, day int) time.Time {
var (
year, month, _ = now.Date()
nextMonth = month + 1
)
if nextMonth > 12 {
nextMonth = 1
year++
}
return time.Date(year, nextMonth, day, 0, 0, 0, 0, now.Location())
}
// FormatDurationCoarse returns a pretty printed duration with coarse granularity.
func FormatDurationCoarse(duration time.Duration) string {
// Negative durations (e.g. future dates) should work too.

View File

@ -7,53 +7,6 @@ import (
"code.nonshy.com/nonshy/website/pkg/utility"
)
func TestNextMonth(t *testing.T) {
var tests = []struct {
Now string
Day int
Expect string
}{
{
Now: "1995-08-01",
Day: 15,
Expect: "1995-09-15",
},
{
Now: "2006-12-15",
Day: 11,
Expect: "2007-01-11",
},
{
Now: "2006-12-01",
Day: 15,
Expect: "2007-01-15",
},
{
Now: "2007-01-15",
Day: 29, // no leap day
Expect: "2007-03-01",
},
{
Now: "2004-01-08",
Day: 29, // leap day
Expect: "2004-02-29",
},
}
for i, test := range tests {
now, err := time.Parse(time.DateOnly, test.Now)
if err != nil {
t.Errorf("Test #%d: parse error: %s", i, err)
continue
}
actual := utility.NextMonth(now, test.Day).Format("2006-01-02")
if actual != test.Expect {
t.Errorf("Test #%d: expected %s but got %s", i, test.Expect, actual)
}
}
}
func TestFormatDurationCoarse(t *testing.T) {
var tests = []struct {
In time.Duration

View File

@ -30,14 +30,6 @@
color: rgb(26, 0, 5) !important;
}
/* hero.is-light.is-bold still is white on Bulma's dark theme */
.hero.is-light.is-bold {
background-image: linear-gradient(141deg, #333 0, #181818 100%);
* {
color: #fff;
}
}
/* force lit-up notification buttons (on the mobile top nav, e.g. new Messages/Friends)
to show as a bright bulma is-warning style (.tag.is-warning) */
.nonshy-navbar-notification {
@ -45,11 +37,6 @@
color: rgb(26, 0, 5) !important;
}
/* glassy background for fixed nav bar when you scroll other elements under it */
nav.navbar {
background-color: rgba(20, 22, 26, .75) !important;
backdrop-filter: blur(5px);
}
.has-text-dark {
/* note: this css file otherwise didn't override this, dark's always dark, brighten it! */
@ -78,19 +65,3 @@ a.has-text-dark:hover {
background-color: #550;
color: #FFC;
}
/*
* Forum Color overrides for dark theme.
*/
.nonshy-forum-box-1 {
/* Outermost box: Forum or Thread top-level wrappers */
background-color: #163b27;
}
.nonshy-forum-box-2 {
/* Nested box: "Latest Post" on Forum-level views */
background-color: #144225;
}
.nonshy-forum-box-3 {
/* Nested box: Topics/Posts/Users/View counters */
background-color: #2c2812;
}

View File

@ -1,3 +1,3 @@
/* Custom nonshy color overrides for Bulma's dark theme
(prefers-dark edition) */
@import url("dark-theme.css?nocache=2") screen and (prefers-color-scheme: dark);
@import url("dark-theme.css") screen and (prefers-color-scheme: dark);

View File

@ -1,15 +0,0 @@
/*
* Forum Color overrides for dark theme.
*/
.nonshy-forum-box-1 {
/* Outermost box: Forum or Thread top-level wrappers */
background-color: #18163b;
}
.nonshy-forum-box-2 {
/* Nested box: "Latest Post" on Forum-level views */
background-color: #142842;
}
.nonshy-forum-box-3 {
/* Nested box: Topics/Posts/Users/View counters */
background-color: #17122c;
}

View File

@ -1,15 +0,0 @@
/*
* Forum Color overrides for dark theme.
*/
.nonshy-forum-box-1 {
/* Outermost box: Forum or Thread top-level wrappers */
background-color: #3b1638;
}
.nonshy-forum-box-2 {
/* Nested box: "Latest Post" on Forum-level views */
background-color: #42143e;
}
.nonshy-forum-box-3 {
/* Nested box: Topics/Posts/Users/View counters */
background-color: #1e122c;
}

View File

@ -1,31 +0,0 @@
:root {
--bulma-primary-h: 300deg;
--bulma-primary-l: 80%;
--bulma-link-h: 204deg;
--bulma-link-l: 50%;
--bulma-scheme-h: 299;
--bulma-scheme-s: 22%;
}
/*
* Forum Colors: with all the nested boxes, Bulma's default color scheme is
* too limited for a good experience.
*/
.nonshy-forum-box-1 {
/* Outermost box: Forum or Thread top-level wrappers */
background-color: #fbd1ff;
}
.nonshy-forum-box-2 {
/* Nested box: "Latest Post" on Forum-level views */
background-color: #ddd0f1;
}
.nonshy-forum-box-3 {
/* Nested box: Topics/Posts/Users/View counters */
background-color: #e7c2ff;
}
/* Chat members in top nav bar on desktop */
.nonshy-navbar-notification-tag.is-link, .nonshy-mobile-notification .tag.is-link {
background-color: rgb(255, 104, 247);
color: #606;
}

View File

@ -1,30 +0,0 @@
:root {
--bulma-primary-h: 204deg;
--bulma-primary-l: 60%;
--bulma-link-h: 200deg;
--bulma-link-l: 34%;
--bulma-scheme-h: 173;
}
/*
* Forum Colors: with all the nested boxes, Bulma's default color scheme is
* too limited for a good experience.
*/
.nonshy-forum-box-1 {
/* Outermost box: Forum or Thread top-level wrappers */
background-color: #d1f0ff;
}
.nonshy-forum-box-2 {
/* Nested box: "Latest Post" on Forum-level views */
background-color: #d6d9ff;
}
.nonshy-forum-box-3 {
/* Nested box: Topics/Posts/Users/View counters */
background-color: #c2d2ff;
}
/* Chat members in top nav bar on desktop */
.nonshy-navbar-notification-tag.is-link {
background-color: rgb(66, 142, 255);
color: #fff;
}

View File

@ -1,15 +0,0 @@
/*
* Forum Color overrides for dark theme.
*/
.nonshy-forum-box-1 {
/* Outermost box: Forum or Thread top-level wrappers */
background-color: #193b16;
}
.nonshy-forum-box-2 {
/* Nested box: "Latest Post" on Forum-level views */
background-color: #2e4214;
}
.nonshy-forum-box-3 {
/* Nested box: Topics/Posts/Users/View counters */
background-color: #122c1f;
}

View File

@ -1,30 +0,0 @@
:root {
--bulma-primary-h: 99deg;
--bulma-link-h: 112deg;
--bulma-link-l: 18%;
--bulma-scheme-h: 95;
--bulma-link-text: #009900;
}
/*
* Forum Colors: with all the nested boxes, Bulma's default color scheme is
* too limited for a good experience.
*/
.nonshy-forum-box-1 {
/* Outermost box: Forum or Thread top-level wrappers */
background-color: #d1ffd1;
}
.nonshy-forum-box-2 {
/* Nested box: "Latest Post" on Forum-level views */
background-color: #e3f1d0;
}
.nonshy-forum-box-3 {
/* Nested box: Topics/Posts/Users/View counters */
background-color: #c2ffe8;
}
/* Chat members in top nav bar on desktop */
.nonshy-navbar-notification-tag.is-link {
background-color: rgb(28, 133, 33);
color: #fff;
}

View File

@ -1,15 +0,0 @@
/*
* Forum Color overrides for dark theme.
*/
.nonshy-forum-box-1 {
/* Outermost box: Forum or Thread top-level wrappers */
background-color: #3b2816;
}
.nonshy-forum-box-2 {
/* Nested box: "Latest Post" on Forum-level views */
background-color: #422a14;
}
.nonshy-forum-box-3 {
/* Nested box: Topics/Posts/Users/View counters */
background-color: #2c1912;
}

View File

@ -1,29 +0,0 @@
:root {
--bulma-primary-h: 39deg;
--bulma-link-h: 21deg;
--bulma-link-l: 39%;
--bulma-scheme-h: 34;
}
/*
* Forum Colors: with all the nested boxes, Bulma's default color scheme is
* too limited for a good experience.
*/
.nonshy-forum-box-1 {
/* Outermost box: Forum or Thread top-level wrappers */
background-color: #ffe2d1;
}
.nonshy-forum-box-2 {
/* Nested box: "Latest Post" on Forum-level views */
background-color: #f1e8d0;
}
.nonshy-forum-box-3 {
/* Nested box: Topics/Posts/Users/View counters */
background-color: #ffcbc2;
}
/* Chat members in top nav bar on desktop */
.nonshy-navbar-notification-tag.is-link {
background-color: rgb(255, 110, 66);
color: #fff;
}

View File

@ -1,15 +0,0 @@
/*
* Forum Color overrides for dark theme.
*/
.nonshy-forum-box-1 {
/* Outermost box: Forum or Thread top-level wrappers */
background-color: #3b1638;
}
.nonshy-forum-box-2 {
/* Nested box: "Latest Post" on Forum-level views */
background-color: #42143e;
}
.nonshy-forum-box-3 {
/* Nested box: Topics/Posts/Users/View counters */
background-color: #1e122c;
}

View File

@ -1,30 +0,0 @@
:root {
--bulma-primary-h: 300deg;
--bulma-primary-l: 80%;
--bulma-link-h: 293deg;
--bulma-scheme-h: 295;
--bulma-scheme-s: 39%;
}
/*
* Forum Colors: with all the nested boxes, Bulma's default color scheme is
* too limited for a good experience.
*/
.nonshy-forum-box-1 {
/* Outermost box: Forum or Thread top-level wrappers */
background-color: #fbd1ff;
}
.nonshy-forum-box-2 {
/* Nested box: "Latest Post" on Forum-level views */
background-color: #ddd0f1;
}
.nonshy-forum-box-3 {
/* Nested box: Topics/Posts/Users/View counters */
background-color: #e7c2ff;
}
/* Chat members in top nav bar on desktop */
.nonshy-navbar-notification-tag.is-link {
background-color: rgb(255, 104, 247);
color: #606;
}

View File

@ -1,15 +0,0 @@
/*
* Forum Color overrides for dark theme.
*/
.nonshy-forum-box-1 {
/* Outermost box: Forum or Thread top-level wrappers */
background-color: #37163b;
}
.nonshy-forum-box-2 {
/* Nested box: "Latest Post" on Forum-level views */
background-color: #3e1442;
}
.nonshy-forum-box-3 {
/* Nested box: Topics/Posts/Users/View counters */
background-color: #1e122c;
}

View File

@ -1,31 +0,0 @@
:root {
--bulma-primary-h: 292deg;
--bulma-primary-l: 60%;
--bulma-link-h: 277deg;
--bulma-link-l: 45%;
--bulma-scheme-h: 293;
--bulma-scheme-s: 23%;
}
/*
* Forum Colors: with all the nested boxes, Bulma's default color scheme is
* too limited for a good experience.
*/
.nonshy-forum-box-1 {
/* Outermost box: Forum or Thread top-level wrappers */
background-color: #f4d1ff;
}
.nonshy-forum-box-2 {
/* Nested box: "Latest Post" on Forum-level views */
background-color: #f1d0ef;
}
.nonshy-forum-box-3 {
/* Nested box: Topics/Posts/Users/View counters */
background-color: #d7c2ff;
}
/* Chat members in top nav bar on desktop */
.nonshy-navbar-notification-tag.is-link {
background-color: rgb(170, 66, 255);
color: #fff;
}

View File

@ -1,15 +0,0 @@
/*
* Forum Color overrides for dark theme.
*/
.nonshy-forum-box-1 {
/* Outermost box: Forum or Thread top-level wrappers */
background-color: #3b1616;
}
.nonshy-forum-box-2 {
/* Nested box: "Latest Post" on Forum-level views */
background-color: #421d14;
}
.nonshy-forum-box-3 {
/* Nested box: Topics/Posts/Users/View counters */
background-color: #2c1912;
}

View File

@ -1,30 +0,0 @@
:root {
--bulma-primary-h: 15deg;
--bulma-primary-l: 63%;
--bulma-link-h: 12deg;
--bulma-link-l: 30%;
--bulma-scheme-h: 0;
}
/*
* Forum Colors: with all the nested boxes, Bulma's default color scheme is
* too limited for a good experience.
*/
.nonshy-forum-box-1 {
/* Outermost box: Forum or Thread top-level wrappers */
background-color: #ffd1d1;
}
.nonshy-forum-box-2 {
/* Nested box: "Latest Post" on Forum-level views */
background-color: #f1d8d0;
}
.nonshy-forum-box-3 {
/* Nested box: Topics/Posts/Users/View counters */
background-color: #ffd0c2;
}
/* Chat members in top nav bar on desktop */
.nonshy-navbar-notification-tag.is-link {
background-color: rgb(255, 66, 66);
color: #fff;
}

View File

@ -1,15 +0,0 @@
/*
* Forum Color overrides for dark theme.
*/
.nonshy-forum-box-1 {
/* Outermost box: Forum or Thread top-level wrappers */
background-color: #3b3516;
}
.nonshy-forum-box-2 {
/* Nested box: "Latest Post" on Forum-level views */
background-color: #423714;
}
.nonshy-forum-box-3 {
/* Nested box: Topics/Posts/Users/View counters */
background-color: #2c2c12;
}

View File

@ -1,31 +0,0 @@
:root {
--bulma-primary-h: 59deg;
--bulma-primary-l: 51%;
--bulma-link-h: 57deg;
--bulma-link-l: 26%;
--bulma-scheme-h: 62;
--bulma-link-text: #999900;
}
/*
* Forum Colors: with all the nested boxes, Bulma's default color scheme is
* too limited for a good experience.
*/
.nonshy-forum-box-1 {
/* Outermost box: Forum or Thread top-level wrappers */
background-color: #ffe2d1;
}
.nonshy-forum-box-2 {
/* Nested box: "Latest Post" on Forum-level views */
background-color: #f1e8d0;
}
.nonshy-forum-box-3 {
/* Nested box: Topics/Posts/Users/View counters */
background-color: #ffcbc2;
}
/* Chat members in top nav bar on desktop */
.nonshy-navbar-notification-tag.is-link {
background-color: rgb(163, 145, 40);
color: #fff;
}

View File

@ -1,11 +1,5 @@
/* Custom CSS styles */
html {
/* With the fixed top nav bar, this works around anchor-links to make them appear
correctly BELOW the nav bar instead of overlapped underneath it. */
scroll-padding-top: 52px;
}
abbr {
cursor: help;
}
@ -22,10 +16,6 @@ abbr {
cursor: default;
}
.has-text-smaller {
font-size: smaller;
}
img {
/* https://stackoverflow.com/questions/12906789/preventing-an-image-from-being-draggable-or-selectable-without-using-js */
user-drag: none;
@ -106,18 +96,12 @@ img {
/* Mobile: notification badge near the hamburger menu */
.nonshy-mobile-notification {
position: fixed;
position: absolute;
top: 10px;
right: 50px;
z-index: 1000;
}
/* glassy background for fixed nav bar when you scroll other elements under it */
nav.navbar {
background-color: rgba(255, 255, 255, .75);
backdrop-filter: blur(5px);
}
/* PWA: loading indicator in the corner of the page */
#nonshy-pwa-loader {
display: none;
@ -165,12 +149,6 @@ nav.navbar {
height: 1.5em !important;
}
/* Bulma breadcrumbs don't word-wrap which can cause horizontal scrolling on mobile,
especially on forum thread pages with a long thread title. */
.breadcrumb {
white-space: inherit;
}
/***
* Mobile navbar notification count badge no.
*/
@ -250,21 +228,4 @@ nav.navbar {
position: absolute;
top: 4px;
right: 4px;
}
/*
* Forum Colors: with all the nested boxes, Bulma's default color scheme is
* too limited for a good experience.
*/
.nonshy-forum-box-1 {
/* Outermost box: Forum or Thread top-level wrappers */
background-color: #d1fff8;
}
.nonshy-forum-box-2 {
/* Nested box: "Latest Post" on Forum-level views */
background-color: #d0f1e2;
}
.nonshy-forum-box-3 {
/* Nested box: Topics/Posts/Users/View counters */
background-color: #ffedc2;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

View File

@ -1,137 +0,0 @@
/**
* Alert and Confirm modals.
*
* Usage:
*
* modalAlert({message: "Hello world!"}).then(callback);
* modalConfirm({message: "Are you sure?"}).then(callback);
*
* Available options for modalAlert:
* - message
* - title: Alert
*
* Available options for modalConfirm:
* - message
* - title: Confirm
* - buttons: ["Ok", "Cancel"]
* - event (pass `event` for easy inline onclick handlers)
* - element (pass `this` for easy inline onclick handlers)
*
* Example onclick for modalConfirm:
*
* <button onclick="modalConfirm({message: 'Are you sure?', event, element}">Delete</button>
*
* The `element` is used to find the nearest <form> and submit it on OK.
* The `event` is used to cancel the submit button's default.
*/
document.addEventListener('DOMContentLoaded', () => {
const $modal = document.querySelector("#nonshy-alert-modal"),
$ok = $modal.querySelector("button.nonshy-alert-ok-button"),
$cancel = $modal.querySelector("button.nonshy-alert-cancel-button"),
$title = $modal.querySelector("#nonshy-alert-modal-title"),
$body = $modal.querySelector("#nonshy-alert-modal-body"),
alertIcon = `<i class="fa fa-exclamation-triangle mr-2"></i>`,
confirmIcon = `<i class="fa fa-question-circle mr-2"></i>`,
cls = 'is-active';
// Current caller's promise.
var currentPromise = null;
const hideModal = () => {
currentPromise = null;
$modal.classList.remove(cls);
};
const showModal = ({
message,
title="Alert",
isConfirm=false,
buttons=["Ok", "Cancel"],
}) => {
$ok.innerHTML = buttons[0];
$cancel.innerHTML = buttons[1];
$cancel.style.display = isConfirm ? "" : "none";
// Strip HTML from message but allow line breaks.
message = message.replace(/</g, "&lt;");
message = message.replace(/>/g, "&gt;");
message = message.replace(/\n/g, "<br>");
$title.innerHTML = (isConfirm ? confirmIcon : alertIcon) + title;
$body.innerHTML = message;
// Show the modal.
$modal.classList.add(cls);
// Focus the OK button, e.g. so hitting Enter doesn't accidentally (re)click the same
// link/button which prompted the alert box in the first place.
window.requestAnimationFrame(() => {
$ok.focus();
});
// Return as a promise.
return new Promise((resolve, reject) => {
currentPromise = resolve;
});
};
// Click events for the modal buttons.
$ok.addEventListener('click', (e) => {
if (currentPromise !== null) {
currentPromise();
}
hideModal();
});
$cancel.addEventListener('click', (e) => {
hideModal();
});
// Key bindings to dismiss the modal.
window.addEventListener('keydown', (e) => {
if ($modal.classList.contains(cls)) {
if (e.key == 'Enter') {
$ok.click();
} else if (e.key == 'Escape') {
$cancel.click();
}
}
});
// Inline submit button confirmation prompts, e.g.: many submit buttons have name="intent"
// and want the user to confirm before submitting, and had inline onclick handlers.
(document.querySelectorAll('.nonshy-confirm-submit') || []).forEach(button => {
const message = button.dataset.confirm;
if (!message) return;
const onclick = (e) => {
e.preventDefault();
modalConfirm({
message: message.replace(/\\n/g, '\n'),
}).then(() => {
button.removeEventListener('click', onclick);
window.requestAnimationFrame(() => {
button.click();
});
});
}
button.addEventListener('click', onclick);
});
// Exported global functions to invoke the modal.
window.modalAlert = async ({ message, title="Alert" }) => {
return showModal({
message,
title,
isConfirm: false,
});
};
window.modalConfirm = async ({ message, title="Confirm", buttons=["Ok", "Cancel"] }) => {
return showModal({
message,
title,
isConfirm: true,
buttons,
});
};
});

View File

@ -8,20 +8,13 @@ document.addEventListener('DOMContentLoaded', function() {
// at the page header instead of going to the dedicated comment page.
(document.querySelectorAll(".nonshy-quote-button") || []).forEach(node => {
const message = node.dataset.quoteBody,
replyTo = node.dataset.replyTo,
commentID = node.dataset.commentId;
// If we have a comment ID, have the at-mention link to it.
let atMention = "@" + replyTo;
if (commentID) {
atMention = `[@${replyTo}](/go/comment?id=${commentID})`;
}
replyTo = node.dataset.replyTo;
node.addEventListener("click", (e) => {
e.preventDefault();
if (replyTo) {
$message.value += atMention + "\n\n";
$message.value += "@" + replyTo + "\n\n";
}
// Prepare the quoted message.
@ -37,18 +30,11 @@ document.addEventListener('DOMContentLoaded', function() {
});
(document.querySelectorAll(".nonshy-reply-button") || []).forEach(node => {
const replyTo = node.dataset.replyTo,
commentID = node.dataset.commentId;
// If we have a comment ID, have the at-mention link to it.
let atMention = "@" + replyTo;
if (commentID) {
atMention = `[@${replyTo}](/go/comment?id=${commentID})`;
}
const replyTo = node.dataset.replyTo;
node.addEventListener("click", (e) => {
e.preventDefault();
$message.value += atMention + "\n\n";
$message.value += "@" + replyTo + "\n\n";
$message.scrollIntoView();
$message.focus();
});

View File

@ -43,7 +43,7 @@ document.addEventListener('DOMContentLoaded', () => {
.then((response) => response.json())
.then((data) => {
if (data.StatusCode !== 200) {
modalAlert({message: data.data.error});
window.alert(data.data.error);
return;
}
@ -54,7 +54,7 @@ document.addEventListener('DOMContentLoaded', () => {
$label.innerHTML = `Like (${likes})`;
}
}).catch(resp => {
console.error("Like:", resp);
window.alert(resp);
}).finally(() => {
busy = false;
})

View File

@ -85,16 +85,15 @@
$dob = document.querySelector("#dob");
$manualEntry.addEventListener("click", function(e) {
$manualEntry.blur();
e.preventDefault();
let answer = window.prompt("Enter your birthdate in 'YYYY-MM-DD' format").trim().replace(/\//g, '-');
if (answer.match(/^(\d{2})-(\d{2})-(\d{4})/)) {
let group = answer.match(/^(\d{2})-(\d{2})-(\d{4})/);
answer = `${group[3]}-${group[1]}-${group[2]}`;
modalAlert({message: `NOTE: Your input was interpreted to be in MM/DD/YYYY order and has been read as: ${answer}`});
window.alert(`NOTE: Your input was interpreted to be in MM/DD/YYYY order and has been read as: ${answer}`);
} else if (!answer.match(/^\d{4}-\d{2}-\d{2}/)) {
modalAlert({message: `Please enter the date in YYYY-MM-DD format.`});
window.alert(`Please enter the date in YYYY-MM-DD format.`);
return;
}

Some files were not shown because too many files have changed in this diff Show More