Compare commits
46 Commits
user-forum
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
391667a94c | ||
|
90b7eea0dc | ||
|
39398a1f78 | ||
|
c1cf5df70e | ||
|
4b582b2141 | ||
|
fcd2cbd615 | ||
|
63471e2e9b | ||
|
ad0eb6e17c | ||
|
7ffcd6b3a8 | ||
|
b52d9df958 | ||
|
1b3e8cb250 | ||
|
e146c09850 | ||
|
704124157d | ||
|
b7bee75e1f | ||
|
cb37934935 | ||
|
26f9c4d71d | ||
|
2262edfe09 | ||
|
77a9d9a7fd | ||
|
8078ff8755 | ||
|
cbdabe791e | ||
|
7869ff83ba | ||
|
295183559d | ||
|
542d0bb300 | ||
|
c8d9cdbb3a | ||
|
106bcd377e | ||
|
f2e847922f | ||
|
3fdae1d8d7 | ||
|
0c7fc7e866 | ||
|
ab880148ad | ||
|
7aa1d512fc | ||
|
9d6c299fdd | ||
|
955ace1e91 | ||
|
0cd72a96ed | ||
|
944b2e28e9 | ||
|
9575041d1e | ||
|
066765d2dc | ||
|
ae84ddf449 | ||
|
7991320256 | ||
|
02487ba2f4 | ||
|
4b43071f28 | ||
|
2f31d678d0 | ||
|
8d9588b039 | ||
|
79ea384d40 | ||
|
463253dbb5 | ||
|
276eddfd8e | ||
|
2c7532434a |
104
README.md
104
README.md
|
@ -20,20 +20,6 @@ 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
|
for local development. The production server runs on PostgreSQL and the
|
||||||
web app is primarily designed for that.
|
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
|
## Building the App
|
||||||
|
|
||||||
This app is written in Go: [go.dev](https://go.dev). You can probably
|
This app is written in Go: [go.dev](https://go.dev). You can probably
|
||||||
|
@ -61,6 +47,96 @@ a database.
|
||||||
For simple local development, just set `"UseSQLite": true` and the
|
For simple local development, just set `"UseSQLite": true` and the
|
||||||
app will run with a SQLite database.
|
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
|
## Usage
|
||||||
|
|
||||||
The `nonshy` binary has sub-commands to either run the web server
|
The `nonshy` binary has sub-commands to either run the web server
|
||||||
|
|
|
@ -261,6 +261,21 @@ func main() {
|
||||||
return err
|
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
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -37,9 +37,10 @@ func MaybeDisconnectUser(user *models.User) (bool, error) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
If: user.IsShy(),
|
If: user.IsShy(),
|
||||||
Message: because + "you had updated your nonshy profile to become too private.<br><br>" +
|
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 join the chat room after you have made your profile and (at least some) pictures " +
|
"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 " +
|
||||||
"viewable on 'public' so that you won't appear to be a blank, faceless profile to others on the chat room.<br><br>" +
|
"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>" +
|
||||||
"Please see the <a href=\"https://www.nonshy.com/faq#shy-faqs\">Shy Account FAQ</a> for more information.",
|
"Please see the <a href=\"https://www.nonshy.com/faq#shy-faqs\">Shy Account FAQ</a> for more information.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
37
pkg/config/admin_labels.go
Normal file
37
pkg/config/admin_labels.go
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -25,6 +25,10 @@ const (
|
||||||
PhotoDiskPath = "./web/static/photos"
|
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
|
// Security
|
||||||
const (
|
const (
|
||||||
BcryptCost = 14
|
BcryptCost = 14
|
||||||
|
@ -38,6 +42,11 @@ const (
|
||||||
|
|
||||||
TwoFactorBackupCodeCount = 12
|
TwoFactorBackupCodeCount = 12
|
||||||
TwoFactorBackupCodeLength = 8 // characters a-z0-9
|
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
|
// Authentication
|
||||||
|
@ -51,6 +60,11 @@ const (
|
||||||
ChangeEmailRedisKey = "change-email/%s"
|
ChangeEmailRedisKey = "change-email/%s"
|
||||||
SignupTokenExpires = 24 * time.Hour // used for all tokens so far
|
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
|
// Rate limits
|
||||||
RateLimitRedisKey = "rate-limit/%s/%s" // namespace, id
|
RateLimitRedisKey = "rate-limit/%s/%s" // namespace, id
|
||||||
LoginRateLimitWindow = 1 * time.Hour
|
LoginRateLimitWindow = 1 * time.Hour
|
||||||
|
@ -78,6 +92,9 @@ const (
|
||||||
|
|
||||||
// Chat room status refresh interval.
|
// Chat room status refresh interval.
|
||||||
ChatStatusRefreshInterval = 30 * time.Second
|
ChatStatusRefreshInterval = 30 * time.Second
|
||||||
|
|
||||||
|
// Cache TTL for the demographics page.
|
||||||
|
DemographicsCacheTTL = time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -113,6 +130,12 @@ const (
|
||||||
// pictures can be posted per day.
|
// pictures can be posted per day.
|
||||||
SiteGalleryRateLimitMax = 5
|
SiteGalleryRateLimitMax = 5
|
||||||
SiteGalleryRateLimitInterval = 24 * time.Hour
|
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
|
// Forum settings
|
||||||
|
|
|
@ -79,10 +79,43 @@ var (
|
||||||
"dm_privacy",
|
"dm_privacy",
|
||||||
"blur_explicit",
|
"blur_explicit",
|
||||||
"site_gallery_default", // default view on site gallery (friends-only or all certified?)
|
"site_gallery_default", // default view on site gallery (friends-only or all certified?)
|
||||||
|
"chat_moderation_rules",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Website theme color hue choices.
|
||||||
|
WebsiteThemeHueChoices = []Option{
|
||||||
|
{
|
||||||
|
Label: "Default (no added color; classic nonshy theme)",
|
||||||
|
Value: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "nonshy blue & pink",
|
||||||
|
Value: "blue-pink",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "Pretty in pink",
|
||||||
|
Value: "pink",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "Royal purple",
|
||||||
|
Value: "purple",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "Cool blue",
|
||||||
|
Value: "blue",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "Burnt red",
|
||||||
|
Value: "red",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Label: "Leafy green",
|
||||||
|
Value: "green",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Choices for the Contact Us subject
|
// Choices for the Contact Us subject
|
||||||
ContactUsChoices = []ContactUs{
|
ContactUsChoices = []OptGroup{
|
||||||
{
|
{
|
||||||
Header: "Website Feedback",
|
Header: "Website Feedback",
|
||||||
Options: []Option{
|
Options: []Option{
|
||||||
|
@ -119,10 +152,34 @@ var (
|
||||||
regexp.MustCompile(`\b(telegram|whats\s*app|signal|kik|session)\b`),
|
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)`),
|
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.",
|
||||||
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// ContactUs choices for the subject drop-down.
|
// OptGroup choices for the subject drop-down.
|
||||||
type ContactUs struct {
|
type OptGroup struct {
|
||||||
Header string
|
Header string
|
||||||
Options []Option
|
Options []Option
|
||||||
}
|
}
|
||||||
|
@ -133,6 +190,13 @@ type Option struct {
|
||||||
Label string
|
Label string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChecklistOption for checkbox-lists.
|
||||||
|
type ChecklistOption struct {
|
||||||
|
Value string
|
||||||
|
Label string
|
||||||
|
Help string
|
||||||
|
}
|
||||||
|
|
||||||
// NotificationOptout field values (stored in user ProfileField table)
|
// NotificationOptout field values (stored in user ProfileField table)
|
||||||
const (
|
const (
|
||||||
NotificationOptOutFriendPhotos = "notif_optout_friends_photos"
|
NotificationOptOutFriendPhotos = "notif_optout_friends_photos"
|
||||||
|
|
|
@ -15,7 +15,7 @@ import (
|
||||||
|
|
||||||
// Version of the config format - when new fields are added, it will attempt
|
// Version of the config format - when new fields are added, it will attempt
|
||||||
// to write the settings.toml to disk so new defaults populate.
|
// to write the settings.toml to disk so new defaults populate.
|
||||||
var currentVersion = 4
|
var currentVersion = 5
|
||||||
|
|
||||||
// Current loaded settings.json
|
// Current loaded settings.json
|
||||||
var Current = DefaultVariable()
|
var Current = DefaultVariable()
|
||||||
|
@ -32,6 +32,7 @@ type Variable struct {
|
||||||
BareRTC BareRTC
|
BareRTC BareRTC
|
||||||
Maintenance Maintenance
|
Maintenance Maintenance
|
||||||
Encryption Encryption
|
Encryption Encryption
|
||||||
|
SignedPhoto SignedPhoto
|
||||||
WebPush WebPush
|
WebPush WebPush
|
||||||
Turnstile Turnstile
|
Turnstile Turnstile
|
||||||
UseXForwardedFor bool
|
UseXForwardedFor bool
|
||||||
|
@ -126,6 +127,12 @@ func LoadSettings() {
|
||||||
writeSettings = true
|
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.
|
// Have we added new config fields? Save the settings.json.
|
||||||
if Current.Version != currentVersion || writeSettings {
|
if Current.Version != currentVersion || writeSettings {
|
||||||
log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.")
|
log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.")
|
||||||
|
@ -196,6 +203,12 @@ type Encryption struct {
|
||||||
ColdStorageRSAPublicKey []byte
|
ColdStorageRSAPublicKey []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SignedPhoto settings.
|
||||||
|
type SignedPhoto struct {
|
||||||
|
Enabled bool
|
||||||
|
JWTSecret string
|
||||||
|
}
|
||||||
|
|
||||||
// WebPush settings.
|
// WebPush settings.
|
||||||
type WebPush struct {
|
type WebPush struct {
|
||||||
VAPIDPublicKey string
|
VAPIDPublicKey string
|
||||||
|
|
|
@ -50,7 +50,7 @@ func Dashboard() http.HandlerFunc {
|
||||||
pager := &models.Pagination{
|
pager := &models.Pagination{
|
||||||
Page: 1,
|
Page: 1,
|
||||||
PerPage: config.PageSizeDashboardNotifications,
|
PerPage: config.PageSizeDashboardNotifications,
|
||||||
Sort: "created_at desc",
|
Sort: "read, created_at desc",
|
||||||
}
|
}
|
||||||
pager.ParsePage(r)
|
pager.ParsePage(r)
|
||||||
notifs, err := models.PaginateNotifications(currentUser, nf, pager)
|
notifs, err := models.PaginateNotifications(currentUser, nf, pager)
|
||||||
|
|
|
@ -3,7 +3,9 @@ package account
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/middleware"
|
"code.nonshy.com/nonshy/website/pkg/middleware"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
|
@ -39,6 +41,7 @@ func Profile() http.HandlerFunc {
|
||||||
|
|
||||||
vars := map[string]interface{}{
|
vars := map[string]interface{}{
|
||||||
"User": user,
|
"User": user,
|
||||||
|
"IsExternalView": true,
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
@ -70,9 +73,9 @@ func Profile() http.HandlerFunc {
|
||||||
// Inject relationship booleans for profile picture display.
|
// Inject relationship booleans for profile picture display.
|
||||||
models.SetUserRelationships(currentUser, []*models.User{user})
|
models.SetUserRelationships(currentUser, []*models.User{user})
|
||||||
|
|
||||||
// Admin user can always see the profile pic - but only on this page. Other avatar displays
|
// Admin user (photo moderator) can always see the profile pic - but only on this page.
|
||||||
// will show the yellow or pink shy.png if the admin is not friends or not granted.
|
// Other avatar displays will show the yellow or pink shy.png if the admin is not friends or not granted.
|
||||||
if currentUser.IsAdmin {
|
if currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||||
user.UserRelationship.IsFriend = true
|
user.UserRelationship.IsFriend = true
|
||||||
user.UserRelationship.IsPrivateGranted = true
|
user.UserRelationship.IsPrivateGranted = true
|
||||||
}
|
}
|
||||||
|
@ -101,6 +104,14 @@ func Profile() http.HandlerFunc {
|
||||||
log.Error("WhoLikes(user %d): %s", user.ID, err)
|
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{}{
|
vars := map[string]interface{}{
|
||||||
"User": user,
|
"User": user,
|
||||||
"LikeMap": likeMap,
|
"LikeMap": likeMap,
|
||||||
|
@ -116,6 +127,9 @@ func Profile() http.HandlerFunc {
|
||||||
"LikeRemainder": likeRemainder,
|
"LikeRemainder": likeRemainder,
|
||||||
"LikeTableName": "users",
|
"LikeTableName": "users",
|
||||||
"LikeTableID": user.ID,
|
"LikeTableID": user.ID,
|
||||||
|
|
||||||
|
// Admin numbers.
|
||||||
|
"NumChatModerationRules": chatModerationRules,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
|
|
@ -135,7 +135,9 @@ func ForgotPassword() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email them their reset link.
|
// 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{
|
if err := mail.Send(mail.Message{
|
||||||
To: user.Email,
|
To: user.Email,
|
||||||
Subject: "Reset your forgotten password",
|
Subject: "Reset your forgotten password",
|
||||||
|
@ -147,6 +149,12 @@ func ForgotPassword() http.HandlerFunc {
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
session.FlashError(w, r, "Error sending an email: %s", err)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// Success message and redirect away.
|
// Success message and redirect away.
|
||||||
session.Flash(w, r, vagueSuccessMessage)
|
session.Flash(w, r, vagueSuccessMessage)
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"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/geoip"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
|
@ -44,6 +45,7 @@ func Search() http.HandlerFunc {
|
||||||
hereFor = r.FormValue("here_for")
|
hereFor = r.FormValue("here_for")
|
||||||
friendSearch = r.FormValue("friends") == "true"
|
friendSearch = r.FormValue("friends") == "true"
|
||||||
likedSearch = r.FormValue("liked") == "true"
|
likedSearch = r.FormValue("liked") == "true"
|
||||||
|
onChatSearch = r.FormValue("on_chat") == "true"
|
||||||
sort = r.FormValue("sort")
|
sort = r.FormValue("sort")
|
||||||
sortOK bool
|
sortOK bool
|
||||||
)
|
)
|
||||||
|
@ -149,6 +151,17 @@ func Search() http.HandlerFunc {
|
||||||
certifiedOnly = true
|
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{
|
pager := &models.Pagination{
|
||||||
PerPage: config.PageSizeMemberSearch,
|
PerPage: config.PageSizeMemberSearch,
|
||||||
Sort: sort,
|
Sort: sort,
|
||||||
|
@ -157,6 +170,7 @@ func Search() http.HandlerFunc {
|
||||||
|
|
||||||
users, err := models.SearchUsers(currentUser, &models.UserSearch{
|
users, err := models.SearchUsers(currentUser, &models.UserSearch{
|
||||||
Username: username,
|
Username: username,
|
||||||
|
InUsername: inUsername,
|
||||||
Gender: gender,
|
Gender: gender,
|
||||||
Orientation: orientation,
|
Orientation: orientation,
|
||||||
MaritalStatus: maritalStatus,
|
MaritalStatus: maritalStatus,
|
||||||
|
@ -213,6 +227,7 @@ func Search() http.HandlerFunc {
|
||||||
"AgeMax": ageMax,
|
"AgeMax": ageMax,
|
||||||
"FriendSearch": friendSearch,
|
"FriendSearch": friendSearch,
|
||||||
"LikedSearch": likedSearch,
|
"LikedSearch": likedSearch,
|
||||||
|
"OnChatSearch": onChatSearch,
|
||||||
"Sort": sort,
|
"Sort": sort,
|
||||||
|
|
||||||
// Restricted Search errors.
|
// Restricted Search errors.
|
||||||
|
|
|
@ -43,6 +43,7 @@ func Settings() http.HandlerFunc {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := map[string]interface{}{
|
vars := map[string]interface{}{
|
||||||
"Enum": config.ProfileEnums,
|
"Enum": config.ProfileEnums,
|
||||||
|
"WebsiteThemeHueChoices": config.WebsiteThemeHueChoices,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the current user in case of updates.
|
// Load the current user in case of updates.
|
||||||
|
@ -62,6 +63,10 @@ func Settings() http.HandlerFunc {
|
||||||
|
|
||||||
// Are we POSTing?
|
// Are we POSTing?
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
|
|
||||||
|
// Will they BECOME a Shy Account with this change?
|
||||||
|
var wasShy = user.IsShy()
|
||||||
|
|
||||||
intent := r.PostFormValue("intent")
|
intent := r.PostFormValue("intent")
|
||||||
switch intent {
|
switch intent {
|
||||||
case "profile":
|
case "profile":
|
||||||
|
@ -182,12 +187,27 @@ func Settings() http.HandlerFunc {
|
||||||
for _, field := range []string{
|
for _, field := range []string{
|
||||||
"hero-text-dark",
|
"hero-text-dark",
|
||||||
"card-lightness",
|
"card-lightness",
|
||||||
"website-theme",
|
"website-theme", // light, dark, auto
|
||||||
} {
|
} {
|
||||||
value := r.PostFormValue(field)
|
value := r.PostFormValue(field)
|
||||||
user.SetProfileField(field, value)
|
user.SetProfileField(field, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Website theme color: constrain to available options.
|
||||||
|
for _, field := range []struct {
|
||||||
|
Name string
|
||||||
|
Options []config.Option
|
||||||
|
}{
|
||||||
|
{"website-theme-hue", config.WebsiteThemeHueChoices},
|
||||||
|
} {
|
||||||
|
value := utility.StringInOptions(
|
||||||
|
r.PostFormValue(field.Name),
|
||||||
|
field.Options,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
user.SetProfileField(field.Name, value)
|
||||||
|
}
|
||||||
|
|
||||||
if err := user.Save(); err != nil {
|
if err := user.Save(); err != nil {
|
||||||
session.FlashError(w, r, "Failed to save user to database: %s", err)
|
session.FlashError(w, r, "Failed to save user to database: %s", err)
|
||||||
}
|
}
|
||||||
|
@ -220,6 +240,7 @@ func Settings() http.HandlerFunc {
|
||||||
var (
|
var (
|
||||||
visibility = models.UserVisibility(r.PostFormValue("visibility"))
|
visibility = models.UserVisibility(r.PostFormValue("visibility"))
|
||||||
dmPrivacy = r.PostFormValue("dm_privacy")
|
dmPrivacy = r.PostFormValue("dm_privacy")
|
||||||
|
ppPrivacy = r.PostFormValue("private_photo_gate")
|
||||||
)
|
)
|
||||||
|
|
||||||
user.Visibility = models.UserVisibilityPublic
|
user.Visibility = models.UserVisibilityPublic
|
||||||
|
@ -232,6 +253,7 @@ func Settings() http.HandlerFunc {
|
||||||
|
|
||||||
// Set profile field prefs.
|
// Set profile field prefs.
|
||||||
user.SetProfileField("dm_privacy", dmPrivacy)
|
user.SetProfileField("dm_privacy", dmPrivacy)
|
||||||
|
user.SetProfileField("private_photo_gate", ppPrivacy)
|
||||||
|
|
||||||
if err := user.Save(); err != nil {
|
if err := user.Save(); err != nil {
|
||||||
session.FlashError(w, r, "Failed to save user to database: %s", err)
|
session.FlashError(w, r, "Failed to save user to database: %s", err)
|
||||||
|
@ -472,9 +494,11 @@ func Settings() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maybe kick them from the chat room if they had become a Shy Account.
|
// 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 {
|
if _, err := chat.MaybeDisconnectUser(user); err != nil {
|
||||||
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
|
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
templates.Redirect(w, r.URL.Path+hashtag+".")
|
templates.Redirect(w, r.URL.Path+hashtag+".")
|
||||||
return
|
return
|
||||||
|
|
|
@ -139,10 +139,13 @@ func Signup() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already an account?
|
// Already an account?
|
||||||
if _, err := models.FindUser(email); err == nil {
|
if user, err := models.FindUser(email); err == nil {
|
||||||
// We don't want to admit that the email already is registered, so send an email to the
|
// 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.
|
// address in case the user legitimately forgot, but flash the regular success message.
|
||||||
if err := mail.LockSending("signup", email, config.SignupTokenExpires); err == nil {
|
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{
|
err := mail.Send(mail.Message{
|
||||||
To: email,
|
To: email,
|
||||||
Subject: "You already have a nonshy account",
|
Subject: "You already have a nonshy account",
|
||||||
|
@ -155,9 +158,11 @@ func Signup() http.HandlerFunc {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Error sending an email: %s", err)
|
session.FlashError(w, r, "Error sending an email: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
log.Error("LockSending: signup e-mail is not sent to %s: one was sent recently", email)
|
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)
|
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)
|
||||||
templates.Redirect(w, r.URL.Path)
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
|
|
@ -18,7 +18,10 @@ func UserNotes() http.HandlerFunc {
|
||||||
tmpl := templates.Must("account/user_notes.html")
|
tmpl := templates.Must("account/user_notes.html")
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Parse the username out of the URL parameters.
|
// Parse the username out of the URL parameters.
|
||||||
var username = r.PathValue("username")
|
var (
|
||||||
|
username = r.PathValue("username")
|
||||||
|
show = r.FormValue("show") // admin feedback filter
|
||||||
|
)
|
||||||
|
|
||||||
// Find this user.
|
// Find this user.
|
||||||
user, err := models.FindUser(username)
|
user, err := models.FindUser(username)
|
||||||
|
@ -108,7 +111,7 @@ func UserNotes() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paginate feedback & reports.
|
// Paginate feedback & reports.
|
||||||
if fb, err := models.PaginateFeedbackAboutUser(user, fbPager); err != nil {
|
if fb, err := models.PaginateFeedbackAboutUser(user, show, fbPager); err != nil {
|
||||||
session.FlashError(w, r, "Paginating feedback on this user: %s", err)
|
session.FlashError(w, r, "Paginating feedback on this user: %s", err)
|
||||||
} else {
|
} else {
|
||||||
feedback = fb
|
feedback = fb
|
||||||
|
@ -141,6 +144,7 @@ func UserNotes() http.HandlerFunc {
|
||||||
"MyNote": myNote,
|
"MyNote": myNote,
|
||||||
|
|
||||||
// Admin concerns.
|
// Admin concerns.
|
||||||
|
"Show": show,
|
||||||
"Feedback": feedback,
|
"Feedback": feedback,
|
||||||
"FeedbackPager": fbPager,
|
"FeedbackPager": fbPager,
|
||||||
"OtherNotes": otherNotes,
|
"OtherNotes": otherNotes,
|
||||||
|
|
|
@ -14,6 +14,13 @@ import (
|
||||||
// Feedback controller (/admin/feedback)
|
// Feedback controller (/admin/feedback)
|
||||||
func Feedback() http.HandlerFunc {
|
func Feedback() http.HandlerFunc {
|
||||||
tmpl := templates.Must("admin/feedback.html")
|
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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Query params.
|
// Query params.
|
||||||
var (
|
var (
|
||||||
|
@ -23,8 +30,26 @@ func Feedback() http.HandlerFunc {
|
||||||
profile = r.FormValue("profile") == "true" // visit associated user profile
|
profile = r.FormValue("profile") == "true" // visit associated user profile
|
||||||
verdict = r.FormValue("verdict")
|
verdict = r.FormValue("verdict")
|
||||||
fb *models.Feedback
|
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)
|
currentUser, err := session.CurrentUser(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't get your current user: %s", err)
|
session.FlashError(w, r, "Couldn't get your current user: %s", err)
|
||||||
|
@ -44,6 +69,15 @@ func Feedback() http.HandlerFunc {
|
||||||
|
|
||||||
// Are we visiting a linked resource (via TableID)?
|
// Are we visiting a linked resource (via TableID)?
|
||||||
if fb != nil && fb.TableID > 0 && visit {
|
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 {
|
switch fb.TableName {
|
||||||
case "users":
|
case "users":
|
||||||
user, err := models.GetUser(fb.TableID)
|
user, err := models.GetUser(fb.TableID)
|
||||||
|
@ -56,15 +90,29 @@ func Feedback() http.HandlerFunc {
|
||||||
case "photos":
|
case "photos":
|
||||||
pic, err := models.GetPhoto(fb.TableID)
|
pic, err := models.GetPhoto(fb.TableID)
|
||||||
if err != nil {
|
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)
|
session.FlashError(w, r, "Couldn't get photo %d: %s", fb.TableID, err)
|
||||||
} else {
|
} else {
|
||||||
// Going to the user's profile page?
|
// Going to the user's profile page?
|
||||||
if profile {
|
if profile {
|
||||||
user, err := models.GetUser(pic.UserID)
|
|
||||||
if err != nil {
|
// Going forward: the aboutUser will be populated, this is for legacy reports.
|
||||||
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
|
if aboutUser == nil {
|
||||||
|
if user, err := models.GetUser(pic.UserID); err == nil {
|
||||||
|
aboutUser = user
|
||||||
} else {
|
} else {
|
||||||
templates.Redirect(w, "/u/"+user.Username)
|
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if aboutUser != nil {
|
||||||
|
templates.Redirect(w, "/u/"+aboutUser.Username)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -89,19 +137,9 @@ func Feedback() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "comments":
|
case "comments":
|
||||||
// Get this comment.
|
// Redirect to the comment redirector.
|
||||||
comment, err := models.GetComment(fb.TableID)
|
templates.Redirect(w, fmt.Sprintf("/go/comment?id=%d", 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
|
return
|
||||||
}
|
|
||||||
}
|
|
||||||
case "forums":
|
case "forums":
|
||||||
// Get this forum.
|
// Get this forum.
|
||||||
forum, err := models.GetForum(fb.TableID)
|
forum, err := models.GetForum(fb.TableID)
|
||||||
|
@ -149,31 +187,51 @@ func Feedback() http.HandlerFunc {
|
||||||
pager := &models.Pagination{
|
pager := &models.Pagination{
|
||||||
Page: 1,
|
Page: 1,
|
||||||
PerPage: config.PageSizeAdminFeedback,
|
PerPage: config.PageSizeAdminFeedback,
|
||||||
Sort: "updated_at desc",
|
Sort: sort,
|
||||||
}
|
}
|
||||||
pager.ParsePage(r)
|
pager.ParsePage(r)
|
||||||
page, err := models.PaginateFeedback(acknowledged, intent, pager)
|
page, err := models.PaginateFeedback(acknowledged, intent, subject, search, pager)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't load feedback from DB: %s", err)
|
session.FlashError(w, r, "Couldn't load feedback from DB: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map user IDs.
|
// Map user IDs.
|
||||||
var userIDs = []uint64{}
|
var (
|
||||||
|
userIDs = []uint64{}
|
||||||
|
photoIDs = []uint64{}
|
||||||
|
)
|
||||||
for _, p := range page {
|
for _, p := range page {
|
||||||
if p.UserID > 0 {
|
if p.UserID > 0 {
|
||||||
userIDs = append(userIDs, p.UserID)
|
userIDs = append(userIDs, p.UserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if p.TableName == "photos" && p.TableID > 0 {
|
||||||
|
photoIDs = append(photoIDs, p.TableID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
userMap, err := models.MapUsers(currentUser, userIDs)
|
userMap, err := models.MapUsers(currentUser, userIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't map user IDs: %s", err)
|
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{}{
|
var vars = map[string]interface{}{
|
||||||
|
// Filter settings.
|
||||||
|
"DistinctSubjects": models.DistinctFeedbackSubjects(),
|
||||||
|
"SearchTerm": searchQuery,
|
||||||
|
"Subject": subject,
|
||||||
|
"Sort": sort,
|
||||||
|
|
||||||
"Intent": intent,
|
"Intent": intent,
|
||||||
"Acknowledged": acknowledged,
|
"Acknowledged": acknowledged,
|
||||||
"Feedback": page,
|
"Feedback": page,
|
||||||
"UserMap": userMap,
|
"UserMap": userMap,
|
||||||
|
"PhotoMap": photoMap,
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
|
|
@ -52,6 +52,7 @@ func MarkPhotoExplicit() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
photo.Explicit = true
|
photo.Explicit = true
|
||||||
|
photo.Flagged = true
|
||||||
if err := photo.Save(); err != nil {
|
if err := photo.Save(); err != nil {
|
||||||
session.FlashError(w, r, "Couldn't save photo: %s", err)
|
session.FlashError(w, r, "Couldn't save photo: %s", err)
|
||||||
} else {
|
} else {
|
||||||
|
@ -117,11 +118,60 @@ func UserActions() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get their block lists.
|
||||||
insights, err := models.GetBlocklistInsights(user)
|
insights, err := models.GetBlocklistInsights(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Error getting blocklist insights: %s", err)
|
session.FlashError(w, r, "Error getting blocklist insights: %s", err)
|
||||||
}
|
}
|
||||||
vars["BlocklistInsights"] = insights
|
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":
|
case "essays":
|
||||||
// Edit their profile essays easily.
|
// Edit their profile essays easily.
|
||||||
if !currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
if !currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||||
|
|
|
@ -79,6 +79,9 @@ func Report() http.HandlerFunc {
|
||||||
|
|
||||||
log.Debug("Got chat report: %+v", report)
|
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.
|
// Create an admin Feedback model.
|
||||||
fb := &models.Feedback{
|
fb := &models.Feedback{
|
||||||
Intent: "report",
|
Intent: "report",
|
||||||
|
@ -87,7 +90,7 @@ func Report() http.HandlerFunc {
|
||||||
"A message was reported on the chat room!\n\n"+
|
"A message was reported on the chat room!\n\n"+
|
||||||
"* From username: [%s](/u/%s)\n"+
|
"* From username: [%s](/u/%s)\n"+
|
||||||
"* About username: [%s](/u/%s)\n"+
|
"* About username: [%s](/u/%s)\n"+
|
||||||
"* Channel: **%s**\n"+
|
"* Channel: [**%s**](/u/%s)\n"+
|
||||||
"* Timestamp: %s\n"+
|
"* Timestamp: %s\n"+
|
||||||
"* Classification: %s\n"+
|
"* Classification: %s\n"+
|
||||||
"* User comment: %s\n\n"+
|
"* User comment: %s\n\n"+
|
||||||
|
@ -95,7 +98,7 @@ func Report() http.HandlerFunc {
|
||||||
"The reported message on chat was:\n\n%s",
|
"The reported message on chat was:\n\n%s",
|
||||||
report.FromUsername, report.FromUsername,
|
report.FromUsername, report.FromUsername,
|
||||||
report.AboutUsername, report.AboutUsername,
|
report.AboutUsername, report.AboutUsername,
|
||||||
report.Channel,
|
report.Channel, otherUsername,
|
||||||
report.Timestamp,
|
report.Timestamp,
|
||||||
report.Reason,
|
report.Reason,
|
||||||
report.Comment,
|
report.Comment,
|
||||||
|
@ -116,6 +119,7 @@ func Report() http.HandlerFunc {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
fb.TableName = "users"
|
fb.TableName = "users"
|
||||||
fb.TableID = targetUser.ID
|
fb.TableID = targetUser.ID
|
||||||
|
fb.AboutUserID = targetUser.ID
|
||||||
} else {
|
} else {
|
||||||
log.Error("BareRTC Chat Feedback: couldn't find user ID for AboutUsername=%s: %s", report.AboutUsername, err)
|
log.Error("BareRTC Chat Feedback: couldn't find user ID for AboutUsername=%s: %s", report.AboutUsername, err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/photo"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -98,26 +99,18 @@ func Likes() http.HandlerFunc {
|
||||||
if user, err := models.GetUser(photo.UserID); err == nil {
|
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.
|
// 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.
|
// Example: you unfriended them but they still had the image on their old browser page.
|
||||||
var unallowed bool
|
if ok, _ := photo.ShouldBeSeenBy(currentUser); !ok {
|
||||||
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{
|
SendJSON(w, http.StatusForbidden, Response{
|
||||||
Error: "You are not allowed to like that photo.",
|
Error: "You are not allowed to like that photo.",
|
||||||
})
|
})
|
||||||
return
|
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
|
targetUser = user
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -204,6 +197,13 @@ 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.
|
// Send success response.
|
||||||
SendJSON(w, http.StatusOK, Response{
|
SendJSON(w, http.StatusOK, Response{
|
||||||
OK: true,
|
OK: true,
|
||||||
|
@ -286,7 +286,7 @@ func WhoLikes() http.HandlerFunc {
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
result = append(result, Liker{
|
result = append(result, Liker{
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Avatar: user.VisibleAvatarURL(currentUser),
|
Avatar: photo.VisibleAvatarURL(user, currentUser),
|
||||||
Relationship: user.UserRelationship,
|
Relationship: user.UserRelationship,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -83,6 +83,7 @@ func MarkPhotoExplicit() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
photo.Explicit = true
|
photo.Explicit = true
|
||||||
|
photo.Flagged = true
|
||||||
if err := photo.Save(); err != nil {
|
if err := photo.Save(); err != nil {
|
||||||
SendJSON(w, http.StatusBadRequest, Response{
|
SendJSON(w, http.StatusBadRequest, Response{
|
||||||
Error: fmt.Sprintf("Couldn't save the photo: %s", err),
|
Error: fmt.Sprintf("Couldn't save the photo: %s", err),
|
||||||
|
@ -90,6 +91,23 @@ func MarkPhotoExplicit() http.HandlerFunc {
|
||||||
return
|
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
|
// 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).
|
// to keep a pulse on things (e.g. in case of abuse).
|
||||||
if !currentUser.IsAdmin {
|
if !currentUser.IsAdmin {
|
||||||
|
|
70
pkg/controller/api/photo.go
Normal file
70
pkg/controller/api/photo.go
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
106
pkg/controller/api/photosign_auth.go
Normal file
106
pkg/controller/api/photosign_auth.go
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
|
@ -112,17 +112,35 @@ func BlockUser() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Can't block admins who have the unblockable scope.
|
// If the target user is an admin, log this to the admin reports page.
|
||||||
if user.IsAdmin && user.HasAdminScope(config.ScopeUnblockable) {
|
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)
|
||||||
|
|
||||||
// For curiosity's sake, log a report.
|
// For curiosity's sake, log a report.
|
||||||
fb := &models.Feedback{
|
fb := &models.Feedback{
|
||||||
Intent: "report",
|
Intent: "report",
|
||||||
Subject: "A user tried to block an admin",
|
Subject: "A user tried to block an admin",
|
||||||
Message: fmt.Sprintf(
|
Message: fmt.Sprintf(
|
||||||
"A user has tried to block an admin user account!\n\n"+
|
"A user has tried to block an admin user account!\n\n"+
|
||||||
"* Username: %s\n* Tried to block: %s",
|
"* Username: %s\n* Tried to block: %s\n\n%s",
|
||||||
currentUser.Username,
|
currentUser.Username,
|
||||||
user.Username,
|
user.Username,
|
||||||
|
footer,
|
||||||
),
|
),
|
||||||
UserID: currentUser.ID,
|
UserID: currentUser.ID,
|
||||||
TableName: "users",
|
TableName: "users",
|
||||||
|
@ -132,10 +150,13 @@ func BlockUser() http.HandlerFunc {
|
||||||
log.Error("Could not log feedback for user %s trying to block admin %s: %s", currentUser.Username, user.Username, err)
|
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.")
|
session.FlashError(w, r, "You can not block site administrators.")
|
||||||
templates.Redirect(w, "/u/"+username)
|
templates.Redirect(w, "/u/"+username)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Block the target user.
|
// Block the target user.
|
||||||
if err := models.AddBlock(currentUser.ID, user.ID); err != nil {
|
if err := models.AddBlock(currentUser.ID, user.ID); err != nil {
|
||||||
|
|
|
@ -3,7 +3,6 @@ package chat
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
@ -11,6 +10,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"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/geoip"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/middleware"
|
"code.nonshy.com/nonshy/website/pkg/middleware"
|
||||||
|
@ -22,7 +22,7 @@ import (
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JWT claims.
|
// Claims are the JWT claims for the BareRTC chat room.
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
// Custom claims.
|
// Custom claims.
|
||||||
IsAdmin bool `json:"op,omitempty"`
|
IsAdmin bool `json:"op,omitempty"`
|
||||||
|
@ -32,6 +32,7 @@ type Claims struct {
|
||||||
Nickname string `json:"nick,omitempty"`
|
Nickname string `json:"nick,omitempty"`
|
||||||
Emoji string `json:"emoji,omitempty"`
|
Emoji string `json:"emoji,omitempty"`
|
||||||
Gender string `json:"gender,omitempty"`
|
Gender string `json:"gender,omitempty"`
|
||||||
|
Rules []string `json:"rules,omitempty"`
|
||||||
|
|
||||||
// Standard claims. Notes:
|
// Standard claims. Notes:
|
||||||
// subject = username
|
// subject = username
|
||||||
|
@ -76,16 +77,6 @@ func Landing() http.HandlerFunc {
|
||||||
return
|
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.
|
// Get our Chat JWT secret.
|
||||||
var (
|
var (
|
||||||
secret = []byte(config.Current.BareRTC.JWTSecret)
|
secret = []byte(config.Current.BareRTC.JWTSecret)
|
||||||
|
@ -98,7 +89,7 @@ func Landing() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avatar URL - masked if non-public.
|
// Avatar URL - masked if non-public.
|
||||||
avatar := photo.URLPath(currentUser.ProfilePhoto.CroppedFilename)
|
avatar := photo.SignedPublicAvatarURL(currentUser.ProfilePhoto.CroppedFilename)
|
||||||
switch currentUser.ProfilePhoto.Visibility {
|
switch currentUser.ProfilePhoto.Visibility {
|
||||||
case models.PhotoPrivate:
|
case models.PhotoPrivate:
|
||||||
avatar = "/static/img/shy-private.png"
|
avatar = "/static/img/shy-private.png"
|
||||||
|
@ -120,6 +111,16 @@ func Landing() http.HandlerFunc {
|
||||||
emoji = "🍰 It's my birthday!"
|
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.
|
// Create the JWT claims.
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator),
|
IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator),
|
||||||
|
@ -128,17 +129,11 @@ func Landing() http.HandlerFunc {
|
||||||
Nickname: currentUser.NameOrUsername(),
|
Nickname: currentUser.NameOrUsername(),
|
||||||
Emoji: emoji,
|
Emoji: emoji,
|
||||||
Gender: Gender(currentUser),
|
Gender: Gender(currentUser),
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
VIP: isShy, // "shy accounts" use the "VIP" status for special icon in chat
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
|
Rules: rules,
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
RegisteredClaims: encryption.StandardClaims(currentUser.ID, currentUser.Username, time.Now().Add(5*time.Minute)),
|
||||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
|
||||||
Issuer: config.Title,
|
|
||||||
Subject: currentUser.Username,
|
|
||||||
ID: fmt.Sprintf("%d", currentUser.ID),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token, err := encryption.SignClaims(claims, []byte(config.Current.BareRTC.JWTSecret))
|
||||||
ss, err := token.SignedString(secret)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't sign you into the chat: %s", err)
|
session.FlashError(w, r, "Couldn't sign you into the chat: %s", err)
|
||||||
templates.Redirect(w, r.URL.Path)
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
@ -154,8 +149,15 @@ func Landing() http.HandlerFunc {
|
||||||
// of time where they can exist in chat but change their name on the site.
|
// of time where they can exist in chat but change their name on the site.
|
||||||
worker.GetChatStatistics().SetOnlineNow(currentUser.Username)
|
worker.GetChatStatistics().SetOnlineNow(currentUser.Username)
|
||||||
|
|
||||||
|
// Ping their chat login usage statistic.
|
||||||
|
go func() {
|
||||||
|
if err := models.LogDailyChatUser(currentUser); err != nil {
|
||||||
|
log.Error("LogDailyChatUser(%s): error logging this user's chat statistic: %s", currentUser.Username, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// Redirect them to the chat room.
|
// Redirect them to the chat room.
|
||||||
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss)
|
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+token)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -121,6 +121,14 @@ func PostComment() http.HandlerFunc {
|
||||||
// Log the change.
|
// Log the change.
|
||||||
models.LogDeleted(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, "Deleted a comment.", comment)
|
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)
|
templates.Redirect(w, fromURL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -174,6 +182,13 @@ func PostComment() http.HandlerFunc {
|
||||||
session.Flash(w, r, "Comment added!")
|
session.Flash(w, r, "Comment added!")
|
||||||
templates.Redirect(w, fromURL)
|
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.
|
// Log the change.
|
||||||
models.LogCreated(currentUser, "comments", comment.ID, "Posted a new comment.\n\n---\n\n"+message)
|
models.LogCreated(currentUser, "comments", comment.ID, "Posted a new comment.\n\n---\n\n"+message)
|
||||||
|
|
||||||
|
|
|
@ -77,7 +77,6 @@ func AddEdit() http.HandlerFunc {
|
||||||
// Sanity check admin-only settings -> default these to OFF.
|
// Sanity check admin-only settings -> default these to OFF.
|
||||||
if !currentUser.HasAdminScope(config.ScopeForumAdmin) {
|
if !currentUser.HasAdminScope(config.ScopeForumAdmin) {
|
||||||
isPrivileged = false
|
isPrivileged = false
|
||||||
isPermitPhotos = false
|
|
||||||
isPrivate = false
|
isPrivate = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,23 +87,23 @@ func AddEdit() http.HandlerFunc {
|
||||||
models.NewFieldDiff("Description", forum.Description, description),
|
models.NewFieldDiff("Description", forum.Description, description),
|
||||||
models.NewFieldDiff("Category", forum.Category, category),
|
models.NewFieldDiff("Category", forum.Category, category),
|
||||||
models.NewFieldDiff("Explicit", forum.Explicit, isExplicit),
|
models.NewFieldDiff("Explicit", forum.Explicit, isExplicit),
|
||||||
|
models.NewFieldDiff("PermitPhotos", forum.PermitPhotos, isPermitPhotos),
|
||||||
}
|
}
|
||||||
|
|
||||||
forum.Title = title
|
forum.Title = title
|
||||||
forum.Description = description
|
forum.Description = description
|
||||||
forum.Category = category
|
forum.Category = category
|
||||||
forum.Explicit = isExplicit
|
forum.Explicit = isExplicit
|
||||||
|
forum.PermitPhotos = isPermitPhotos
|
||||||
|
|
||||||
// Forum Admin-only options: if the current viewer is not a forum admin, do not change these settings.
|
// 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!
|
// e.g.: the front-end checkboxes are hidden and don't want to accidentally unset these!
|
||||||
if currentUser.HasAdminScope(config.ScopeForumAdmin) {
|
if currentUser.HasAdminScope(config.ScopeForumAdmin) {
|
||||||
diffs = append(diffs,
|
diffs = append(diffs,
|
||||||
models.NewFieldDiff("Privileged", forum.Privileged, isPrivileged),
|
models.NewFieldDiff("Privileged", forum.Privileged, isPrivileged),
|
||||||
models.NewFieldDiff("PermitPhotos", forum.PermitPhotos, isPermitPhotos),
|
|
||||||
models.NewFieldDiff("Private", forum.Private, isPrivate),
|
models.NewFieldDiff("Private", forum.Private, isPrivate),
|
||||||
)
|
)
|
||||||
forum.Privileged = isPrivileged
|
forum.Privileged = isPrivileged
|
||||||
forum.PermitPhotos = isPermitPhotos
|
|
||||||
forum.Private = isPrivate
|
forum.Private = isPrivate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -55,7 +55,7 @@ func Forum() http.HandlerFunc {
|
||||||
var pager = &models.Pagination{
|
var pager = &models.Pagination{
|
||||||
Page: 1,
|
Page: 1,
|
||||||
PerPage: config.PageSizeThreadList,
|
PerPage: config.PageSizeThreadList,
|
||||||
Sort: "updated_at desc",
|
Sort: "threads.updated_at desc",
|
||||||
}
|
}
|
||||||
pager.ParsePage(r)
|
pager.ParsePage(r)
|
||||||
|
|
||||||
|
|
|
@ -109,7 +109,14 @@ func NewPost() http.HandlerFunc {
|
||||||
if len(quoteCommentID) > 0 {
|
if len(quoteCommentID) > 0 {
|
||||||
if i, err := strconv.Atoi(quoteCommentID); err == nil {
|
if i, err := strconv.Atoi(quoteCommentID); err == nil {
|
||||||
if comment, err := models.GetComment(uint64(i)); err == nil {
|
if comment, err := models.GetComment(uint64(i)); err == nil {
|
||||||
message = markdown.Quotify(comment.Message) + "\n\n"
|
|
||||||
|
// 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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -97,6 +97,13 @@ func Thread() http.HandlerFunc {
|
||||||
// Is the current user subscribed to notifications on this thread?
|
// Is the current user subscribed to notifications on this thread?
|
||||||
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
|
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
|
||||||
|
|
||||||
|
// Ping this user as having used the forums today.
|
||||||
|
go func() {
|
||||||
|
if err := models.LogDailyForumUser(currentUser); err != nil {
|
||||||
|
log.Error("LogDailyForumUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"Forum": forum,
|
"Forum": forum,
|
||||||
"Thread": thread,
|
"Thread": thread,
|
||||||
|
|
|
@ -26,12 +26,14 @@ func Contact() http.HandlerFunc {
|
||||||
subject = r.FormValue("subject")
|
subject = r.FormValue("subject")
|
||||||
title = "Contact Us"
|
title = "Contact Us"
|
||||||
message = r.FormValue("message")
|
message = r.FormValue("message")
|
||||||
|
footer string // appends to the message only when posting the feedback
|
||||||
replyTo = r.FormValue("email")
|
replyTo = r.FormValue("email")
|
||||||
trap1 = r.FormValue("url") != "https://"
|
trap1 = r.FormValue("url") != "https://"
|
||||||
trap2 = r.FormValue("comment") != ""
|
trap2 = r.FormValue("comment") != ""
|
||||||
tableID int
|
tableID int
|
||||||
tableName string
|
tableName string
|
||||||
tableLabel string // front-end user feedback about selected report item
|
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
|
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."
|
success = "Thank you for your feedback! Your message has been delivered to the website administrators."
|
||||||
)
|
)
|
||||||
|
@ -55,6 +57,7 @@ func Contact() http.HandlerFunc {
|
||||||
tableName = "users"
|
tableName = "users"
|
||||||
if user, err := models.GetUser(uint64(tableID)); err == nil {
|
if user, err := models.GetUser(uint64(tableID)); err == nil {
|
||||||
tableLabel = fmt.Sprintf(`User account "%s"`, user.Username)
|
tableLabel = fmt.Sprintf(`User account "%s"`, user.Username)
|
||||||
|
aboutUser = user
|
||||||
} else {
|
} else {
|
||||||
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
|
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
|
||||||
}
|
}
|
||||||
|
@ -65,6 +68,7 @@ func Contact() http.HandlerFunc {
|
||||||
if pic, err := models.GetPhoto(uint64(tableID)); err == nil {
|
if pic, err := models.GetPhoto(uint64(tableID)); err == nil {
|
||||||
if user, err := models.GetUser(pic.UserID); err == nil {
|
if user, err := models.GetUser(pic.UserID); err == nil {
|
||||||
tableLabel = fmt.Sprintf(`A profile photo of user account "%s"`, user.Username)
|
tableLabel = fmt.Sprintf(`A profile photo of user account "%s"`, user.Username)
|
||||||
|
aboutUser = user
|
||||||
} else {
|
} else {
|
||||||
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
|
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
|
||||||
}
|
}
|
||||||
|
@ -74,12 +78,43 @@ func Contact() http.HandlerFunc {
|
||||||
case "report.message":
|
case "report.message":
|
||||||
tableName = "messages"
|
tableName = "messages"
|
||||||
tableLabel = "Direct Message conversation"
|
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":
|
case "report.comment":
|
||||||
tableName = "comments"
|
tableName = "comments"
|
||||||
|
|
||||||
// Find this comment.
|
// Find this comment.
|
||||||
if comment, err := models.GetComment(uint64(tableID)); err == nil {
|
if comment, err := models.GetComment(uint64(tableID)); err == nil {
|
||||||
tableLabel = fmt.Sprintf(`A comment written by "%s"`, comment.User.Username)
|
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 {
|
} else {
|
||||||
log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err)
|
log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err)
|
||||||
}
|
}
|
||||||
|
@ -141,11 +176,15 @@ func Contact() http.HandlerFunc {
|
||||||
fb := &models.Feedback{
|
fb := &models.Feedback{
|
||||||
Intent: intent,
|
Intent: intent,
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
Message: message,
|
Message: message + footer,
|
||||||
TableName: tableName,
|
TableName: tableName,
|
||||||
TableID: uint64(tableID),
|
TableID: uint64(tableID),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if aboutUser != nil {
|
||||||
|
fb.AboutUserID = aboutUser.ID
|
||||||
|
}
|
||||||
|
|
||||||
if currentUser != nil && currentUser.ID > 0 {
|
if currentUser != nil && currentUser.ID > 0 {
|
||||||
fb.UserID = currentUser.ID
|
fb.UserID = currentUser.ID
|
||||||
} else if replyTo != "" {
|
} else if replyTo != "" {
|
||||||
|
|
57
pkg/controller/index/demographics.go
Normal file
57
pkg/controller/index/demographics.go
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
package index
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/models/demographic"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Demographics page (/insights) to show a peek at website demographics.
|
||||||
|
func Demographics() http.HandlerFunc {
|
||||||
|
tmpl := templates.Must("demographics.html")
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
refresh = r.FormValue("refresh") == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Are we refreshing? Check if an admin is logged in.
|
||||||
|
if refresh {
|
||||||
|
currentUser, err := session.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "You must be logged in to do that!")
|
||||||
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do the refresh?
|
||||||
|
if currentUser.IsAdmin {
|
||||||
|
_, err := demographic.Refresh()
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Refreshing the insights: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get website statistics to show on home page.
|
||||||
|
demo, err := demographic.Get()
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't get website statistics: %s", err)
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := map[string]interface{}{
|
||||||
|
"Demographic": demo,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import (
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/models/demographic"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -18,7 +19,17 @@ func Create() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tmpl.Execute(w, r, nil); err != nil {
|
// Get website statistics to show on home page.
|
||||||
|
demo, err := demographic.Get()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("demographic.Get: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := map[string]interface{}{
|
||||||
|
"Demographic": demo,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
235
pkg/controller/photo/batch_edit.go
Normal file
235
pkg/controller/photo/batch_edit.go
Normal file
|
@ -0,0 +1,235 @@
|
||||||
|
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 {
|
||||||
|
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, redirectURI)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
if owner, ok := owners[photo.UserID]; ok {
|
||||||
|
models.LogDeleted(owner, currentUser, "photos", photo.ID, "Deleted the photo.", photo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
|
@ -434,9 +434,10 @@ func AdminCertification() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify the user via email.
|
// Notify the user via email.
|
||||||
|
if err := mail.LockSending("cert_rejected", user.Email, config.EmailDebounceDefault); err == nil {
|
||||||
if err := mail.Send(mail.Message{
|
if err := mail.Send(mail.Message{
|
||||||
To: user.Email,
|
To: user.Email,
|
||||||
Subject: "Your certification photo has been rejected",
|
Subject: "Your certification photo has been denied",
|
||||||
Template: "email/certification_rejected.html",
|
Template: "email/certification_rejected.html",
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"Username": user.Username,
|
"Username": user.Username,
|
||||||
|
@ -446,6 +447,9 @@ func AdminCertification() http.HandlerFunc {
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
session.FlashError(w, r, "Note: failed to email user about the rejection: %s", err)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
session.Flash(w, r, "Certification photo rejected!")
|
session.Flash(w, r, "Certification photo rejected!")
|
||||||
|
@ -503,6 +507,7 @@ func AdminCertification() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify the user via email.
|
// Notify the user via email.
|
||||||
|
if err := mail.LockSending("cert_approved", user.Email, config.EmailDebounceDefault); err == nil {
|
||||||
if err := mail.Send(mail.Message{
|
if err := mail.Send(mail.Message{
|
||||||
To: user.Email,
|
To: user.Email,
|
||||||
Subject: "Your certification photo has been approved!",
|
Subject: "Your certification photo has been approved!",
|
||||||
|
@ -514,6 +519,9 @@ func AdminCertification() http.HandlerFunc {
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
session.FlashError(w, r, "Note: failed to email user about the approval: %s", err)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// Log the change.
|
// Log the change.
|
||||||
models.LogEvent(user, currentUser, models.ChangeLogApproved, "certification_photos", user.ID, "Approved the certification photo.")
|
models.LogEvent(user, currentUser, models.ChangeLogApproved, "certification_photos", user.ID, "Approved the certification photo.")
|
||||||
|
|
|
@ -71,6 +71,9 @@ func Edit() http.HandlerFunc {
|
||||||
|
|
||||||
// Are we saving the changes?
|
// Are we saving the changes?
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
|
// Record if this change is going to make them a Shy Account.
|
||||||
|
var wasShy = currentUser.IsShy()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
caption = strings.TrimSpace(r.FormValue("caption"))
|
caption = strings.TrimSpace(r.FormValue("caption"))
|
||||||
altText = strings.TrimSpace(r.FormValue("alt_text"))
|
altText = strings.TrimSpace(r.FormValue("alt_text"))
|
||||||
|
@ -85,6 +88,9 @@ func Edit() http.HandlerFunc {
|
||||||
|
|
||||||
// Are we GOING private?
|
// Are we GOING private?
|
||||||
goingPrivate = visibility == models.PhotoPrivate && visibility != photo.Visibility
|
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 {
|
if len(altText) > config.AltTextMaxLength {
|
||||||
|
@ -105,6 +111,24 @@ func Edit() http.HandlerFunc {
|
||||||
models.NewFieldDiff("Visibility", photo.Visibility, visibility),
|
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.Caption = caption
|
||||||
photo.AltText = altText
|
photo.AltText = altText
|
||||||
photo.Explicit = isExplicit
|
photo.Explicit = isExplicit
|
||||||
|
@ -138,6 +162,34 @@ func Edit() http.HandlerFunc {
|
||||||
setProfilePic = false
|
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 {
|
if err := photo.Save(); err != nil {
|
||||||
session.FlashError(w, r, "Couldn't save photo: %s", err)
|
session.FlashError(w, r, "Couldn't save photo: %s", err)
|
||||||
}
|
}
|
||||||
|
@ -158,9 +210,12 @@ func Edit() http.HandlerFunc {
|
||||||
models.LogUpdated(currentUser, requestUser, "photos", photo.ID, "Updated the photo's settings.", diffs)
|
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.
|
// 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 {
|
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
|
||||||
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
|
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.
|
// If this picture has moved to Private, revoke any notification we gave about it before.
|
||||||
if goingPrivate {
|
if goingPrivate {
|
||||||
|
@ -177,6 +232,10 @@ func Edit() http.HandlerFunc {
|
||||||
"EditPhoto": photo,
|
"EditPhoto": photo,
|
||||||
"SiteGalleryThrottled": SiteGalleryThrottled,
|
"SiteGalleryThrottled": SiteGalleryThrottled,
|
||||||
"SiteGalleryThrottleLimit": config.SiteGalleryRateLimitMax,
|
"SiteGalleryThrottleLimit": config.SiteGalleryRateLimitMax,
|
||||||
|
|
||||||
|
// Available admin labels enum.
|
||||||
|
"RequestUser": requestUser,
|
||||||
|
"AvailableAdminLabels": config.AdminLabelPhotoOptions,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
@ -187,121 +246,10 @@ func Edit() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete controller (/photo/Delete?id=N) to change properties about your picture.
|
// Delete controller (/photo/Delete?id=N) to change properties about your picture.
|
||||||
|
//
|
||||||
|
// DEPRECATED: send them to the batch-edit endpoint.
|
||||||
func Delete() http.HandlerFunc {
|
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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Query params.
|
templates.Redirect(w, fmt.Sprintf("/photo/batch-edit?intent=delete&id=%s", r.FormValue("id")))
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,6 +42,12 @@ func Private() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect user IDs for some mappings.
|
||||||
|
var userIDs = []uint64{}
|
||||||
|
for _, user := range users {
|
||||||
|
userIDs = append(userIDs, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
// Map reverse grantee statuses.
|
// Map reverse grantee statuses.
|
||||||
var GranteeMap interface{}
|
var GranteeMap interface{}
|
||||||
if isGrantee {
|
if isGrantee {
|
||||||
|
@ -58,6 +64,12 @@ func Private() http.HandlerFunc {
|
||||||
"GranteeMap": GranteeMap,
|
"GranteeMap": GranteeMap,
|
||||||
"Users": users,
|
"Users": users,
|
||||||
"Pager": pager,
|
"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 {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
@ -129,6 +141,15 @@ func Share() http.HandlerFunc {
|
||||||
intent = r.PostFormValue("intent")
|
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 submitting, do it and redirect.
|
||||||
if intent == "submit" {
|
if intent == "submit" {
|
||||||
models.UnlockPrivatePhotos(currentUser.ID, user.ID)
|
models.UnlockPrivatePhotos(currentUser.ID, user.ID)
|
||||||
|
@ -164,6 +185,21 @@ func Share() http.HandlerFunc {
|
||||||
log.Error("RevokePrivatePhotoNotifications(%s): %s", currentUser.Username, err)
|
log.Error("RevokePrivatePhotoNotifications(%s): %s", currentUser.Username, err)
|
||||||
}
|
}
|
||||||
return
|
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
|
// The other intent is "preview" so the user gets the confirmation
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
@ -17,6 +18,9 @@ func SiteGallery() http.HandlerFunc {
|
||||||
var sortWhitelist = []string{
|
var sortWhitelist = []string{
|
||||||
"created_at desc",
|
"created_at desc",
|
||||||
"created_at asc",
|
"created_at asc",
|
||||||
|
"like_count desc",
|
||||||
|
"comment_count desc",
|
||||||
|
"views desc",
|
||||||
}
|
}
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
@ -121,6 +125,13 @@ func SiteGallery() http.HandlerFunc {
|
||||||
likeMap := models.MapLikes(currentUser, "photos", photoIDs)
|
likeMap := models.MapLikes(currentUser, "photos", photoIDs)
|
||||||
commentMap := models.MapCommentCounts("photos", photoIDs)
|
commentMap := models.MapCommentCounts("photos", photoIDs)
|
||||||
|
|
||||||
|
// Ping this user as having used the forums today.
|
||||||
|
go func() {
|
||||||
|
if err := models.LogDailyGalleryUser(currentUser); err != nil {
|
||||||
|
log.Error("LogDailyGalleryUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"IsSiteGallery": true,
|
"IsSiteGallery": true,
|
||||||
"Photos": photos,
|
"Photos": photos,
|
||||||
|
|
|
@ -19,6 +19,9 @@ func UserPhotos() http.HandlerFunc {
|
||||||
"pinned desc nulls last, updated_at desc",
|
"pinned desc nulls last, updated_at desc",
|
||||||
"created_at desc",
|
"created_at desc",
|
||||||
"created_at asc",
|
"created_at asc",
|
||||||
|
"like_count desc",
|
||||||
|
"comment_count desc",
|
||||||
|
"views desc",
|
||||||
}
|
}
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
@ -192,12 +195,16 @@ func UserPhotos() http.HandlerFunc {
|
||||||
areNotificationsMuted = !v
|
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{}{
|
var vars = map[string]interface{}{
|
||||||
"IsOwnPhotos": currentUser.ID == user.ID,
|
"IsOwnPhotos": currentUser.ID == user.ID,
|
||||||
"IsShyUser": isShy,
|
"IsShyUser": isShy,
|
||||||
"IsShyFrom": isShyFrom,
|
"IsShyFrom": isShyFrom,
|
||||||
"IsMyPrivateUnlockedFor": isGranted, // have WE granted THIS USER to see our private pics?
|
"IsMyPrivateUnlockedFor": isGranted, // have WE granted THIS USER to see our private pics?
|
||||||
"AreWeGrantedPrivate": isGrantee, // have THEY granted US private photo access.
|
"AreWeGrantedPrivate": isGrantee, // have THEY granted US private photo access.
|
||||||
|
"ShowPrivateUnlockPrompt": showPrivateUnlockPrompt,
|
||||||
"AreFriends": areFriends,
|
"AreFriends": areFriends,
|
||||||
"AreNotificationsMuted": areNotificationsMuted,
|
"AreNotificationsMuted": areNotificationsMuted,
|
||||||
"ProfilePictureHiddenVisibility": profilePictureHidden,
|
"ProfilePictureHiddenVisibility": profilePictureHidden,
|
||||||
|
|
|
@ -35,6 +35,12 @@ 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.
|
// Find the photo's owner.
|
||||||
user, err := models.GetUser(photo.UserID)
|
user, err := models.GetUser(photo.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -42,34 +48,10 @@ func View() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the current user in case they are viewing their own page.
|
if ok, err := photo.CanBeSeenBy(currentUser); !ok {
|
||||||
currentUser, err := session.CurrentUser(r)
|
log.Error("Photo %d can't be seen by %s: %s", photo.ID, currentUser.Username, err)
|
||||||
if err != nil {
|
session.FlashError(w, r, "Photo Not Found")
|
||||||
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
templates.Redirect(w, "/")
|
||||||
}
|
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,6 +84,11 @@ func View() http.HandlerFunc {
|
||||||
// Is the current user subscribed to notifications on this thread?
|
// Is the current user subscribed to notifications on this thread?
|
||||||
_, isSubscribed := models.IsSubscribed(currentUser, "photos", photo.ID)
|
_, 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{}{
|
var vars = map[string]interface{}{
|
||||||
"IsOwnPhoto": currentUser.ID == user.ID,
|
"IsOwnPhoto": currentUser.ID == user.ID,
|
||||||
"User": user,
|
"User": user,
|
||||||
|
|
71
pkg/encryption/jwt.go
Normal file
71
pkg/encryption/jwt.go
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -48,9 +48,8 @@ func LoginRequired(handler http.Handler) http.Handler {
|
||||||
// Ping LastLoginAt for long lived sessions, but not if impersonated.
|
// Ping LastLoginAt for long lived sessions, but not if impersonated.
|
||||||
var pingLastLoginAt bool
|
var pingLastLoginAt bool
|
||||||
if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown && !session.Impersonated(r) {
|
if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown && !session.Impersonated(r) {
|
||||||
user.LastLoginAt = time.Now()
|
|
||||||
pingLastLoginAt = true
|
pingLastLoginAt = true
|
||||||
if err := user.Save(); err != nil {
|
if err := user.PingLastLoginAt(); err != nil {
|
||||||
log.Error("LoginRequired: couldn't refresh LastLoginAt for user %s: %s", user.Username, err)
|
log.Error("LoginRequired: couldn't refresh LastLoginAt for user %s: %s", user.Username, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
25
pkg/models/backfill/backfill_photo_counts.go
Normal file
25
pkg/models/backfill/backfill_photo_counts.go
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -199,6 +199,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
||||||
reverse = []*Block{} // Users who block the target
|
reverse = []*Block{} // Users who block the target
|
||||||
userIDs = []uint64{user.ID}
|
userIDs = []uint64{user.ID}
|
||||||
usernames = map[uint64]string{}
|
usernames = map[uint64]string{}
|
||||||
|
admins = map[uint64]bool{}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get the complete blocklist and bucket them into forward and reverse.
|
// Get the complete blocklist and bucket them into forward and reverse.
|
||||||
|
@ -218,6 +219,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
||||||
type scanItem struct {
|
type scanItem struct {
|
||||||
ID uint64
|
ID uint64
|
||||||
Username string
|
Username string
|
||||||
|
IsAdmin bool
|
||||||
}
|
}
|
||||||
var scan = []scanItem{}
|
var scan = []scanItem{}
|
||||||
if res := DB.Table(
|
if res := DB.Table(
|
||||||
|
@ -225,6 +227,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
||||||
).Select(
|
).Select(
|
||||||
"id",
|
"id",
|
||||||
"username",
|
"username",
|
||||||
|
"is_admin",
|
||||||
).Where(
|
).Where(
|
||||||
"id IN ?", userIDs,
|
"id IN ?", userIDs,
|
||||||
).Scan(&scan); res.Error != nil {
|
).Scan(&scan); res.Error != nil {
|
||||||
|
@ -233,6 +236,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
||||||
|
|
||||||
for _, row := range scan {
|
for _, row := range scan {
|
||||||
usernames[row.ID] = row.Username
|
usernames[row.ID] = row.Username
|
||||||
|
admins[row.ID] = row.IsAdmin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -245,6 +249,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
||||||
if username, ok := usernames[row.TargetUserID]; ok {
|
if username, ok := usernames[row.TargetUserID]; ok {
|
||||||
result.Blocks = append(result.Blocks, BlocklistInsightUser{
|
result.Blocks = append(result.Blocks, BlocklistInsightUser{
|
||||||
Username: username,
|
Username: username,
|
||||||
|
IsAdmin: admins[row.TargetUserID],
|
||||||
Date: row.CreatedAt,
|
Date: row.CreatedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -253,6 +258,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
||||||
if username, ok := usernames[row.SourceUserID]; ok {
|
if username, ok := usernames[row.SourceUserID]; ok {
|
||||||
result.BlockedBy = append(result.BlockedBy, BlocklistInsightUser{
|
result.BlockedBy = append(result.BlockedBy, BlocklistInsightUser{
|
||||||
Username: username,
|
Username: username,
|
||||||
|
IsAdmin: admins[row.SourceUserID],
|
||||||
Date: row.CreatedAt,
|
Date: row.CreatedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -268,6 +274,7 @@ type BlocklistInsight struct {
|
||||||
|
|
||||||
type BlocklistInsightUser struct {
|
type BlocklistInsightUser struct {
|
||||||
Username string
|
Username string
|
||||||
|
IsAdmin bool
|
||||||
Date time.Time
|
Date time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,8 +13,8 @@ import (
|
||||||
// Comment table - in forum threads, on profiles or photos, etc.
|
// Comment table - in forum threads, on profiles or photos, etc.
|
||||||
type Comment struct {
|
type Comment struct {
|
||||||
ID uint64 `gorm:"primaryKey"`
|
ID uint64 `gorm:"primaryKey"`
|
||||||
TableName string `gorm:"index"`
|
TableName string `gorm:"index:idx_comment_composite"`
|
||||||
TableID uint64 `gorm:"index"`
|
TableID uint64 `gorm:"index:idx_comment_composite"`
|
||||||
UserID uint64 `gorm:"index"`
|
UserID uint64 `gorm:"index"`
|
||||||
User User `json:"-"`
|
User User `json:"-"`
|
||||||
Message string
|
Message string
|
||||||
|
|
|
@ -59,6 +59,7 @@ func DeleteUser(user *models.User) error {
|
||||||
{"IP Addresses", DeleteIPAddresses},
|
{"IP Addresses", DeleteIPAddresses},
|
||||||
{"Push Notifications", DeletePushNotifications},
|
{"Push Notifications", DeletePushNotifications},
|
||||||
{"Forum Memberships", DeleteForumMemberships},
|
{"Forum Memberships", DeleteForumMemberships},
|
||||||
|
{"Usage Statistics", DeleteUsageStatistics},
|
||||||
}
|
}
|
||||||
for _, item := range todo {
|
for _, item := range todo {
|
||||||
if err := item.Fn(user.ID); err != nil {
|
if err := item.Fn(user.ID); err != nil {
|
||||||
|
@ -406,3 +407,13 @@ func DeleteForumMemberships(userID uint64) error {
|
||||||
).Delete(&models.ForumMembership{})
|
).Delete(&models.ForumMembership{})
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteUsageStatistics scrubs data for deleting a user.
|
||||||
|
func DeleteUsageStatistics(userID uint64) error {
|
||||||
|
log.Error("DeleteUser: DeleteUsageStatistics(%d)", userID)
|
||||||
|
result := models.DB.Where(
|
||||||
|
"user_id = ?",
|
||||||
|
userID,
|
||||||
|
).Delete(&models.UsageStatistic{})
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
196
pkg/models/demographic/demographic.go
Normal file
196
pkg/models/demographic/demographic.go
Normal file
|
@ -0,0 +1,196 @@
|
||||||
|
// 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
|
||||||
|
}
|
302
pkg/models/demographic/queries.go
Normal file
302
pkg/models/demographic/queries.go
Normal file
|
@ -0,0 +1,302 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -19,30 +19,33 @@ func ExportModels(zw *zip.Writer, user *models.User) error {
|
||||||
// List of tables to export. Keep the ordering in sync with
|
// List of tables to export. Keep the ordering in sync with
|
||||||
// the AutoMigrate() calls in ../models.go
|
// the AutoMigrate() calls in ../models.go
|
||||||
var todo = []task{
|
var todo = []task{
|
||||||
{"User", ExportUserTable},
|
// Note: AdminGroup info is eager-loaded in User export
|
||||||
|
{"Block", ExportBlockTable},
|
||||||
|
{"CertificationPhoto", ExportCertificationPhotoTable},
|
||||||
|
{"ChangeLog", ExportChangeLogTable},
|
||||||
|
{"Comment", ExportCommentTable},
|
||||||
|
{"CommentPhoto", ExportCommentPhotoTable},
|
||||||
|
{"Feedback", ExportFeedbackTable},
|
||||||
|
{"ForumMembership", ExportForumMembershipTable},
|
||||||
|
{"Friend", ExportFriendTable},
|
||||||
|
{"Forum", ExportForumTable},
|
||||||
|
{"IPAddress", ExportIPAddressTable},
|
||||||
|
{"Like", ExportLikeTable},
|
||||||
|
{"Message", ExportMessageTable},
|
||||||
|
{"Notification", ExportNotificationTable},
|
||||||
{"ProfileField", ExportProfileFieldTable},
|
{"ProfileField", ExportProfileFieldTable},
|
||||||
{"Photo", ExportPhotoTable},
|
{"Photo", ExportPhotoTable},
|
||||||
{"PrivatePhoto", ExportPrivatePhotoTable},
|
|
||||||
{"CertificationPhoto", ExportCertificationPhotoTable},
|
|
||||||
{"Message", ExportMessageTable},
|
|
||||||
{"Friend", ExportFriendTable},
|
|
||||||
{"Block", ExportBlockTable},
|
|
||||||
{"Feedback", ExportFeedbackTable},
|
|
||||||
{"Forum", ExportForumTable},
|
|
||||||
{"Thread", ExportThreadTable},
|
|
||||||
{"Comment", ExportCommentTable},
|
|
||||||
{"Like", ExportLikeTable},
|
|
||||||
{"Notification", ExportNotificationTable},
|
|
||||||
{"Subscription", ExportSubscriptionTable},
|
|
||||||
{"CommentPhoto", ExportCommentPhotoTable},
|
|
||||||
// Note: Poll table is eager-loaded in Thread export
|
// Note: Poll table is eager-loaded in Thread export
|
||||||
{"PollVote", ExportPollVoteTable},
|
{"PollVote", ExportPollVoteTable},
|
||||||
// Note: AdminGroup info is eager-loaded in User export
|
{"PrivatePhoto", ExportPrivatePhotoTable},
|
||||||
|
{"PushNotification", ExportPushNotificationTable},
|
||||||
|
{"Subscription", ExportSubscriptionTable},
|
||||||
|
{"Thread", ExportThreadTable},
|
||||||
|
{"TwoFactor", ExportTwoFactorTable},
|
||||||
|
{"UsageStatistic", ExportUsageStatisticTable},
|
||||||
|
{"User", ExportUserTable},
|
||||||
{"UserLocation", ExportUserLocationTable},
|
{"UserLocation", ExportUserLocationTable},
|
||||||
{"UserNote", ExportUserNoteTable},
|
{"UserNote", ExportUserNoteTable},
|
||||||
{"ChangeLog", ExportChangeLogTable},
|
|
||||||
{"TwoFactor", ExportTwoFactorTable},
|
|
||||||
{"IPAddress", ExportIPAddressTable},
|
|
||||||
}
|
}
|
||||||
for _, item := range todo {
|
for _, item := range todo {
|
||||||
log.Info("Exporting data model: %s", item.Step)
|
log.Info("Exporting data model: %s", item.Step)
|
||||||
|
@ -444,3 +447,48 @@ func ExportIPAddressTable(zw *zip.Writer, user *models.User) error {
|
||||||
|
|
||||||
return ZipJson(zw, "ip_addresses.json", items)
|
return ZipJson(zw, "ip_addresses.json", items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ExportForumMembershipTable(zw *zip.Writer, user *models.User) error {
|
||||||
|
var (
|
||||||
|
items = []*models.ForumMembership{}
|
||||||
|
query = models.DB.Model(&models.ForumMembership{}).Where(
|
||||||
|
"user_id = ?",
|
||||||
|
user.ID,
|
||||||
|
).Find(&items)
|
||||||
|
)
|
||||||
|
if query.Error != nil {
|
||||||
|
return query.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return ZipJson(zw, "forum_memberships.json", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExportPushNotificationTable(zw *zip.Writer, user *models.User) error {
|
||||||
|
var (
|
||||||
|
items = []*models.PushNotification{}
|
||||||
|
query = models.DB.Model(&models.PushNotification{}).Where(
|
||||||
|
"user_id = ?",
|
||||||
|
user.ID,
|
||||||
|
).Find(&items)
|
||||||
|
)
|
||||||
|
if query.Error != nil {
|
||||||
|
return query.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return ZipJson(zw, "push_notifications.json", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExportUsageStatisticTable(zw *zip.Writer, user *models.User) error {
|
||||||
|
var (
|
||||||
|
items = []*models.UsageStatistic{}
|
||||||
|
query = models.DB.Model(&models.UsageStatistic{}).Where(
|
||||||
|
"user_id = ?",
|
||||||
|
user.ID,
|
||||||
|
).Find(&items)
|
||||||
|
)
|
||||||
|
if query.Error != nil {
|
||||||
|
return query.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return ZipJson(zw, "usage_statistics.json", items)
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -11,6 +12,7 @@ import (
|
||||||
type Feedback struct {
|
type Feedback struct {
|
||||||
ID uint64 `gorm:"primaryKey"`
|
ID uint64 `gorm:"primaryKey"`
|
||||||
UserID uint64 `gorm:"index"` // if logged-in user posted this
|
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
|
Acknowledged bool `gorm:"index"` // admin dashboard "read" status
|
||||||
Intent string
|
Intent string
|
||||||
Subject string
|
Subject string
|
||||||
|
@ -45,7 +47,7 @@ func CountUnreadFeedback() int64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaginateFeedback
|
// PaginateFeedback
|
||||||
func PaginateFeedback(acknowledged bool, intent string, pager *Pagination) ([]*Feedback, error) {
|
func PaginateFeedback(acknowledged bool, intent, subject string, search *Search, pager *Pagination) ([]*Feedback, error) {
|
||||||
var (
|
var (
|
||||||
fb = []*Feedback{}
|
fb = []*Feedback{}
|
||||||
wheres = []string{}
|
wheres = []string{}
|
||||||
|
@ -60,6 +62,23 @@ func PaginateFeedback(acknowledged bool, intent string, pager *Pagination) ([]*F
|
||||||
placeholders = append(placeholders, intent)
|
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(
|
query := DB.Where(
|
||||||
strings.Join(wheres, " AND "),
|
strings.Join(wheres, " AND "),
|
||||||
placeholders...,
|
placeholders...,
|
||||||
|
@ -81,19 +100,49 @@ func PaginateFeedback(acknowledged bool, intent string, pager *Pagination) ([]*F
|
||||||
// It returns reports where table_name=users and their user ID, or where table_name=photos and about any
|
// 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
|
// of their current photo IDs. Additionally, it will look for chat room reports which were about their
|
||||||
// username.
|
// username.
|
||||||
func PaginateFeedbackAboutUser(user *User, pager *Pagination) ([]*Feedback, error) {
|
//
|
||||||
|
// 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) {
|
||||||
var (
|
var (
|
||||||
fb = []*Feedback{}
|
fb = []*Feedback{}
|
||||||
photoIDs, _ = user.AllPhotoIDs()
|
photoIDs, _ = user.AllPhotoIDs()
|
||||||
wheres = []string{}
|
wheres = []string{}
|
||||||
placeholders = []interface{}{}
|
placeholders = []interface{}{}
|
||||||
|
like = "%" + user.Username + "%"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// How to apply the search filters?
|
||||||
|
switch show {
|
||||||
|
case "about":
|
||||||
wheres = append(wheres, `
|
wheres = append(wheres, `
|
||||||
|
about_user_id = ? OR
|
||||||
(table_name = 'users' AND table_id = ?) OR
|
(table_name = 'users' AND table_id = ?) OR
|
||||||
(table_name = 'photos' AND table_id IN ?)
|
(table_name = 'photos' AND table_id IN ?)
|
||||||
`)
|
`)
|
||||||
placeholders = append(placeholders, user.ID, photoIDs)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
query := DB.Where(
|
query := DB.Where(
|
||||||
strings.Join(wheres, " AND "),
|
strings.Join(wheres, " AND "),
|
||||||
|
@ -111,6 +160,22 @@ func PaginateFeedbackAboutUser(user *User, pager *Pagination) ([]*Feedback, erro
|
||||||
return fb, result.Error
|
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.
|
// CreateFeedback saves a new Feedback row to the DB.
|
||||||
func CreateFeedback(fb *Feedback) error {
|
func CreateFeedback(fb *Feedback) error {
|
||||||
result := DB.Create(fb)
|
result := DB.Create(fb)
|
||||||
|
|
|
@ -140,7 +140,10 @@ func PaginateForums(user *User, categories []string, search *Search, subscribed
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
AND forum_id = forums.id
|
AND forum_id = forums.id
|
||||||
)
|
)
|
||||||
OR forums.owner_id = ?
|
OR (
|
||||||
|
forums.owner_id = ?
|
||||||
|
AND (forums.category = '' OR forums.category IS NULL)
|
||||||
|
)
|
||||||
`)
|
`)
|
||||||
placeholders = append(placeholders, user.ID, user.ID)
|
placeholders = append(placeholders, user.ID, user.ID)
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,8 +11,8 @@ import (
|
||||||
type Like struct {
|
type Like struct {
|
||||||
ID uint64 `gorm:"primaryKey"`
|
ID uint64 `gorm:"primaryKey"`
|
||||||
UserID uint64 `gorm:"index"` // who it belongs to
|
UserID uint64 `gorm:"index"` // who it belongs to
|
||||||
TableName string `gorm:"index"`
|
TableName string `gorm:"index:idx_likes_composite"`
|
||||||
TableID uint64 `gorm:"index"`
|
TableID uint64 `gorm:"index:idx_likes_composite"`
|
||||||
CreatedAt time.Time `gorm:"index"`
|
CreatedAt time.Time `gorm:"index"`
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
|
@ -169,6 +169,16 @@ func HasMessageThread(a, b *User) (uint64, bool) {
|
||||||
return 0, false
|
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.
|
// DeleteMessageThread removes all message history between two people.
|
||||||
func DeleteMessageThread(message *Message) error {
|
func DeleteMessageThread(message *Message) error {
|
||||||
return DB.Where(
|
return DB.Where(
|
||||||
|
|
|
@ -31,9 +31,10 @@ func AutoMigrate() {
|
||||||
&Poll{}, // vacuum script cleans up orphaned polls
|
&Poll{}, // vacuum script cleans up orphaned polls
|
||||||
&PrivatePhoto{}, // ✔
|
&PrivatePhoto{}, // ✔
|
||||||
&PushNotification{}, // ✔
|
&PushNotification{}, // ✔
|
||||||
|
&Subscription{}, // ✔
|
||||||
&Thread{}, // ✔
|
&Thread{}, // ✔
|
||||||
&TwoFactor{}, // ✔
|
&TwoFactor{}, // ✔
|
||||||
&Subscription{}, // ✔
|
&UsageStatistic{}, // ✔
|
||||||
&User{}, // ✔
|
&User{}, // ✔
|
||||||
&UserLocation{}, // ✔
|
&UserLocation{}, // ✔
|
||||||
&UserNote{}, // ✔
|
&UserNote{}, // ✔
|
||||||
|
|
|
@ -46,6 +46,7 @@ const (
|
||||||
NotificationPrivatePhoto NotificationType = "private_photo" // private photo grants
|
NotificationPrivatePhoto NotificationType = "private_photo" // private photo grants
|
||||||
NotificationNewPhoto NotificationType = "new_photo"
|
NotificationNewPhoto NotificationType = "new_photo"
|
||||||
NotificationForumModerator NotificationType = "forum_moderator" // appointed as a forum moderator
|
NotificationForumModerator NotificationType = "forum_moderator" // appointed as a forum moderator
|
||||||
|
NotificationExplicitPhoto NotificationType = "explicit_photo" // user photo was flagged explicit
|
||||||
NotificationCustom NotificationType = "custom" // custom message pushed
|
NotificationCustom NotificationType = "custom" // custom message pushed
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -85,7 +85,13 @@ func (nf NotificationFilter) Query() (where string, placeholders []interface{},
|
||||||
types = append(types, NotificationPrivatePhoto)
|
types = append(types, NotificationPrivatePhoto)
|
||||||
}
|
}
|
||||||
if nf.Misc {
|
if nf.Misc {
|
||||||
types = append(types, NotificationFriendApproved, NotificationCertApproved, NotificationCertRejected, NotificationCustom)
|
types = append(types,
|
||||||
|
NotificationFriendApproved,
|
||||||
|
NotificationCertApproved,
|
||||||
|
NotificationCertRejected,
|
||||||
|
NotificationExplicitPhoto,
|
||||||
|
NotificationCustom,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "type IN ?", types, true
|
return "type IN ?", types, true
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/redis"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -21,10 +22,14 @@ type Photo struct {
|
||||||
Caption string
|
Caption string
|
||||||
AltText string
|
AltText string
|
||||||
Flagged bool // photo has been reported by the community
|
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"`
|
Visibility PhotoVisibility `gorm:"index"`
|
||||||
Gallery bool `gorm:"index"` // photo appears in the public gallery (if public)
|
Gallery bool `gorm:"index"` // photo appears in the public gallery (if public)
|
||||||
Explicit bool `gorm:"index"` // is an explicit photo
|
Explicit bool `gorm:"index"` // is an explicit photo
|
||||||
Pinned bool `gorm:"index"` // user pins it to the front of their gallery
|
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"`
|
CreatedAt time.Time `gorm:"index"`
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
@ -103,6 +108,71 @@ func GetPhotos(IDs []uint64) (map[uint64]*Photo, error) {
|
||||||
return mp, result.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.
|
// UserGallery configuration for filtering gallery pages.
|
||||||
type UserGallery struct {
|
type UserGallery struct {
|
||||||
Explicit string // "", "true", "false"
|
Explicit string // "", "true", "false"
|
||||||
|
@ -152,6 +222,35 @@ func PaginateUserPhotos(userID uint64, conf UserGallery, pager *Pagination) ([]*
|
||||||
return p, result.Error
|
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.
|
// CountPhotos returns the total number of photos on a user's account.
|
||||||
func CountPhotos(userID uint64) int64 {
|
func CountPhotos(userID uint64) int64 {
|
||||||
var count int64
|
var count int64
|
||||||
|
@ -187,6 +286,69 @@ func GetOrphanedPhotos() ([]*Photo, int64, error) {
|
||||||
return ps, count, res.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.
|
IsSiteGalleryThrottled returns whether the user is throttled from marking additional pictures for the Site Gallery.
|
||||||
|
|
||||||
|
@ -289,6 +451,11 @@ func CountPhotosICanSee(user *User, viewer *User) int64 {
|
||||||
// MapPhotoCounts returns a mapping of user ID to the CountPhotos()-equivalent result for each.
|
// 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.
|
// It's used on the member directory to show photo counts on each user card.
|
||||||
func MapPhotoCounts(users []*User) PhotoCountMap {
|
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 (
|
var (
|
||||||
userIDs = []uint64{}
|
userIDs = []uint64{}
|
||||||
result = PhotoCountMap{}
|
result = PhotoCountMap{}
|
||||||
|
@ -309,7 +476,7 @@ func MapPhotoCounts(users []*User) PhotoCountMap {
|
||||||
).Select(
|
).Select(
|
||||||
"user_id, count(id) AS photo_count",
|
"user_id, count(id) AS photo_count",
|
||||||
).Where(
|
).Where(
|
||||||
"user_id IN ? AND visibility = ?", userIDs, PhotoPublic,
|
"user_id IN ? AND visibility = ?", userIDs, visibility,
|
||||||
).Group("user_id").Scan(&groups); res.Error != nil {
|
).Group("user_id").Scan(&groups); res.Error != nil {
|
||||||
log.Error("CountPhotosForUsers: %s", res.Error)
|
log.Error("CountPhotosForUsers: %s", res.Error)
|
||||||
}
|
}
|
||||||
|
@ -428,16 +595,21 @@ func CountExplicitPhotos(userID uint64, visibility []PhotoVisibility) (int64, er
|
||||||
|
|
||||||
// CountPublicPhotos returns the number of public photos on a user's page.
|
// CountPublicPhotos returns the number of public photos on a user's page.
|
||||||
func CountPublicPhotos(userID uint64) int64 {
|
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(
|
query := DB.Where(
|
||||||
"user_id = ? AND visibility = ?",
|
"user_id = ? AND visibility = ?",
|
||||||
userID,
|
userID,
|
||||||
PhotoPublic,
|
visibility,
|
||||||
)
|
)
|
||||||
|
|
||||||
var count int64
|
var count int64
|
||||||
result := query.Model(&Photo{}).Count(&count)
|
result := query.Model(&Photo{}).Count(&count)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
log.Error("CountPublicPhotos(%d): %s", userID, result.Error)
|
log.Error("CountUserPhotosByVisibility(%d, %s): %s", userID, visibility, result.Error)
|
||||||
}
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
@ -472,6 +644,13 @@ func (u *User) DistinctPhotoTypes() (result map[PhotoVisibility]struct{}) {
|
||||||
return
|
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.
|
// Gallery config for the main Gallery paginator.
|
||||||
type Gallery struct {
|
type Gallery struct {
|
||||||
Explicit string // Explicit filter
|
Explicit string // Explicit filter
|
||||||
|
@ -545,9 +724,9 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
|
||||||
// Shy users can only see their Friends photos (public or friends visibility)
|
// Shy users can only see their Friends photos (public or friends visibility)
|
||||||
// and any Private photos to whom they were granted access.
|
// and any Private photos to whom they were granted access.
|
||||||
visOrs = append(visOrs,
|
visOrs = append(visOrs,
|
||||||
fmt.Sprintf("(user_id IN %s AND visibility IN ?)", friendsQuery),
|
fmt.Sprintf("(photos.user_id IN %s AND photos.visibility IN ?)", friendsQuery),
|
||||||
"(user_id IN ? AND visibility IN ?)",
|
"(photos.user_id IN ? AND photos.visibility IN ?)",
|
||||||
"user_id = ?",
|
"photos.user_id = ?",
|
||||||
)
|
)
|
||||||
visPlaceholders = append(visPlaceholders,
|
visPlaceholders = append(visPlaceholders,
|
||||||
photosFriends,
|
photosFriends,
|
||||||
|
@ -557,23 +736,23 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
|
||||||
} else if friendsOnly {
|
} else if friendsOnly {
|
||||||
// User wants to see only self and friends photos.
|
// User wants to see only self and friends photos.
|
||||||
visOrs = append(visOrs,
|
visOrs = append(visOrs,
|
||||||
fmt.Sprintf("(user_id IN %s AND visibility IN ?)", friendsQuery),
|
fmt.Sprintf("(photos.user_id IN %s AND photos.visibility IN ?)", friendsQuery),
|
||||||
"user_id = ?",
|
"photos.user_id = ?",
|
||||||
)
|
)
|
||||||
visPlaceholders = append(visPlaceholders, photosFriends, userID)
|
visPlaceholders = append(visPlaceholders, photosFriends, userID)
|
||||||
|
|
||||||
// If their friends granted private photos, include those too.
|
// If their friends granted private photos, include those too.
|
||||||
if len(privateUserIDsAreFriends) > 0 {
|
if len(privateUserIDsAreFriends) > 0 {
|
||||||
visOrs = append(visOrs, "(user_id IN ? AND visibility IN ?)")
|
visOrs = append(visOrs, "(photos.user_id IN ? AND photos.visibility IN ?)")
|
||||||
visPlaceholders = append(visPlaceholders, privateUserIDsAreFriends, photosPrivate)
|
visPlaceholders = append(visPlaceholders, privateUserIDsAreFriends, photosPrivate)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// You can see friends' Friend photos but only public for non-friends.
|
// You can see friends' Friend photos but only public for non-friends.
|
||||||
visOrs = append(visOrs,
|
visOrs = append(visOrs,
|
||||||
fmt.Sprintf("(user_id IN %s AND visibility IN ?)", friendsQuery),
|
fmt.Sprintf("(photos.user_id IN %s AND photos.visibility IN ?)", friendsQuery),
|
||||||
"(user_id IN ? AND visibility IN ?)",
|
"(photos.user_id IN ? AND photos.visibility IN ?)",
|
||||||
fmt.Sprintf("(user_id NOT IN %s AND visibility IN ?)", friendsQuery),
|
fmt.Sprintf("(photos.user_id NOT IN %s AND photos.visibility IN ?)", friendsQuery),
|
||||||
"user_id = ?",
|
"photos.user_id = ?",
|
||||||
)
|
)
|
||||||
visPlaceholders = append(placeholders,
|
visPlaceholders = append(placeholders,
|
||||||
photosFriends,
|
photosFriends,
|
||||||
|
@ -588,7 +767,7 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
|
||||||
placeholders = append(placeholders, visPlaceholders...)
|
placeholders = append(placeholders, visPlaceholders...)
|
||||||
|
|
||||||
// Gallery photos only.
|
// Gallery photos only.
|
||||||
wheres = append(wheres, "gallery = ?")
|
wheres = append(wheres, "photos.gallery = ?")
|
||||||
placeholders = append(placeholders, true)
|
placeholders = append(placeholders, true)
|
||||||
|
|
||||||
// Filter by photos the user has liked.
|
// Filter by photos the user has liked.
|
||||||
|
@ -597,9 +776,9 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM likes
|
FROM likes
|
||||||
WHERE user_id = ?
|
WHERE likes.user_id = ?
|
||||||
AND table_name = 'photos'
|
AND likes.table_name = 'photos'
|
||||||
AND table_id = photos.id
|
AND likes.table_id = photos.id
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
placeholders = append(placeholders, user.ID)
|
placeholders = append(placeholders, user.ID)
|
||||||
|
@ -607,39 +786,39 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
|
||||||
|
|
||||||
// Filter blocked users.
|
// Filter blocked users.
|
||||||
if len(blocklist) > 0 {
|
if len(blocklist) > 0 {
|
||||||
wheres = append(wheres, "user_id NOT IN ?")
|
wheres = append(wheres, "photos.user_id NOT IN ?")
|
||||||
placeholders = append(placeholders, blocklist)
|
placeholders = append(placeholders, blocklist)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-explicit pics unless the user opted in. Allow explicit filter setting to override.
|
// Non-explicit pics unless the user opted in. Allow explicit filter setting to override.
|
||||||
if filterExplicit != "" {
|
if filterExplicit != "" {
|
||||||
wheres = append(wheres, "explicit = ?")
|
wheres = append(wheres, "photos.explicit = ?")
|
||||||
placeholders = append(placeholders, filterExplicit == "true")
|
placeholders = append(placeholders, filterExplicit == "true")
|
||||||
} else if !explicitOK {
|
} else if !explicitOK {
|
||||||
wheres = append(wheres, "explicit = ?")
|
wheres = append(wheres, "photos.explicit = ?")
|
||||||
placeholders = append(placeholders, false)
|
placeholders = append(placeholders, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Is the user furthermore clamping the visibility filter?
|
// Is the user furthermore clamping the visibility filter?
|
||||||
if filterVisibility != "" {
|
if filterVisibility != "" {
|
||||||
wheres = append(wheres, "visibility = ?")
|
wheres = append(wheres, "photos.visibility = ?")
|
||||||
placeholders = append(placeholders, filterVisibility)
|
placeholders = append(placeholders, filterVisibility)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only certified (and not banned) user photos.
|
// Only certified (and not banned) user photos.
|
||||||
if conf.Uncertified {
|
if conf.Uncertified {
|
||||||
wheres = append(wheres,
|
wheres = append(wheres,
|
||||||
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND certified IS NOT true AND status='active')",
|
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND users.certified IS NOT true AND users.status='active')",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
wheres = append(wheres,
|
wheres = append(wheres,
|
||||||
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND certified = true AND status='active')",
|
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND users.certified = true AND users.status='active')",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exclude private users' photos.
|
// Exclude private users' photos.
|
||||||
wheres = append(wheres,
|
wheres = append(wheres,
|
||||||
"NOT EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND visibility = 'private')",
|
"NOT EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND photos.visibility = 'private')",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Admin view: get ALL PHOTOS on the site, period.
|
// Admin view: get ALL PHOTOS on the site, period.
|
||||||
|
@ -648,14 +827,14 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
|
||||||
|
|
||||||
// Admin may filter too.
|
// Admin may filter too.
|
||||||
if filterVisibility != "" {
|
if filterVisibility != "" {
|
||||||
query = query.Where("visibility = ?", filterVisibility)
|
query = query.Where("photos.visibility = ?", filterVisibility)
|
||||||
}
|
}
|
||||||
if filterExplicit != "" {
|
if filterExplicit != "" {
|
||||||
query = query.Where("explicit = ?", filterExplicit == "true")
|
query = query.Where("photos.explicit = ?", filterExplicit == "true")
|
||||||
}
|
}
|
||||||
if conf.Uncertified {
|
if conf.Uncertified {
|
||||||
query = query.Where(
|
query = query.Where(
|
||||||
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND certified IS NOT true AND status='active')",
|
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND users.certified IS NOT true AND users.status='active')",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -666,12 +845,32 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
|
||||||
}
|
}
|
||||||
|
|
||||||
query = query.Order(pager.Sort)
|
query = query.Order(pager.Sort)
|
||||||
|
|
||||||
query.Model(&Photo{}).Count(&pager.Total)
|
query.Model(&Photo{}).Count(&pager.Total)
|
||||||
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&p)
|
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&p)
|
||||||
return p, result.Error
|
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.
|
// Save photo.
|
||||||
func (p *Photo) Save() error {
|
func (p *Photo) Save() error {
|
||||||
result := DB.Save(p)
|
result := DB.Save(p)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -125,6 +126,43 @@ func (u *User) AllPhotoIDs() ([]uint64, error) {
|
||||||
return photoIDs, nil
|
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.
|
// IsPrivateUnlocked quickly sees if sourceUserID has unlocked private photos for targetUserID to see.
|
||||||
func IsPrivateUnlocked(sourceUserID, targetUserID uint64) bool {
|
func IsPrivateUnlocked(sourceUserID, targetUserID uint64) bool {
|
||||||
pb := &PrivatePhoto{}
|
pb := &PrivatePhoto{}
|
||||||
|
|
105
pkg/models/usage_statistic.go
Normal file
105
pkg/models/usage_statistic.go
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
/*
|
||||||
|
UsageStatistic holds basic analytics points for things like daily/monthly active user counts.
|
||||||
|
|
||||||
|
Generally, there will be one UserStatistic row for each combination of a UserID and Type for
|
||||||
|
each calendar day of the year. Type names may be like "dau" to log daily logins (Daily Active User),
|
||||||
|
or "chat" to log daily chat room users.
|
||||||
|
|
||||||
|
If a user logs in multiple times in the same day, their existing UsageStatistic for that day
|
||||||
|
is reused and the Counter is incremented. So if a user joins chat 3 times on the same day, there
|
||||||
|
will be a single row for that date for that user, but with a Counter of 3 in that case.
|
||||||
|
|
||||||
|
This makes it easier to query for aggregate reports on daily/monthly active users since each
|
||||||
|
row/event type combo only appears once per user per day.
|
||||||
|
*/
|
||||||
|
type UsageStatistic struct {
|
||||||
|
ID uint64 `gorm:"primaryKey"`
|
||||||
|
UserID uint64 `gorm:"uniqueIndex:idx_usage_statistics"`
|
||||||
|
Type string `gorm:"uniqueIndex:idx_usage_statistics"`
|
||||||
|
Date string `gorm:"uniqueIndex:idx_usage_statistics"` // unique days, yyyy-mm-dd format.
|
||||||
|
Counter uint64
|
||||||
|
CreatedAt time.Time `gorm:"index"` // full timestamps
|
||||||
|
UpdatedAt time.Time `gorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options for UsageStatistic Type values.
|
||||||
|
const (
|
||||||
|
UsageStatisticDailyVisit = "dau" // daily active user counter
|
||||||
|
UsageStatisticChatEntry = "chat" // daily chat room users
|
||||||
|
UsageStatisticForumUser = "forum" // daily forum users (when they open a thread)
|
||||||
|
UsageStatisticGalleryUser = "gallery" // daily Site Gallery user (when viewing the site gallery)
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogDailyActiveUser will ping a UserStatistic for the current user to mark them present for the day.
|
||||||
|
func LogDailyActiveUser(user *User) error {
|
||||||
|
var (
|
||||||
|
date = time.Now().Format(time.DateOnly)
|
||||||
|
_, err = IncrementUsageStatistic(user, UsageStatisticDailyVisit, date)
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogDailyChatUser will ping a UserStatistic for the current user to mark them as having used the chat room today.
|
||||||
|
func LogDailyChatUser(user *User) error {
|
||||||
|
var (
|
||||||
|
date = time.Now().Format(time.DateOnly)
|
||||||
|
_, err = IncrementUsageStatistic(user, UsageStatisticChatEntry, date)
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogDailyForumUser will ping a UserStatistic for the current user to mark them as having used the forums today.
|
||||||
|
func LogDailyForumUser(user *User) error {
|
||||||
|
var (
|
||||||
|
date = time.Now().Format(time.DateOnly)
|
||||||
|
_, err = IncrementUsageStatistic(user, UsageStatisticForumUser, date)
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogDailyGalleryUser will ping a UserStatistic for the current user to mark them as having used the site gallery today.
|
||||||
|
func LogDailyGalleryUser(user *User) error {
|
||||||
|
var (
|
||||||
|
date = time.Now().Format(time.DateOnly)
|
||||||
|
_, err = IncrementUsageStatistic(user, UsageStatisticGalleryUser, date)
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUsageStatistic looks up a user statistic.
|
||||||
|
func GetUsageStatistic(user *User, statType, date string) (*UsageStatistic, error) {
|
||||||
|
var (
|
||||||
|
result = &UsageStatistic{}
|
||||||
|
res = DB.Model(&UsageStatistic{}).Where(
|
||||||
|
"user_id = ? AND type = ? AND date = ?",
|
||||||
|
user.ID, statType, date,
|
||||||
|
).First(&result)
|
||||||
|
)
|
||||||
|
return result, res.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementUsageStatistic finds or creates a UserStatistic type and increments the counter.
|
||||||
|
func IncrementUsageStatistic(user *User, statType, date string) (*UsageStatistic, error) {
|
||||||
|
user.muStatistic.Lock()
|
||||||
|
defer user.muStatistic.Unlock()
|
||||||
|
|
||||||
|
// Is there an existing row?
|
||||||
|
stat, err := GetUsageStatistic(user, statType, date)
|
||||||
|
if err != nil {
|
||||||
|
stat = &UsageStatistic{
|
||||||
|
UserID: user.ID,
|
||||||
|
Type: statType,
|
||||||
|
Counter: 0,
|
||||||
|
Date: date,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update and save it.
|
||||||
|
stat.Counter++
|
||||||
|
err = DB.Save(stat).Error
|
||||||
|
return stat, err
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
@ -45,6 +46,9 @@ type User struct {
|
||||||
cachePhotoTypes map[PhotoVisibility]struct{}
|
cachePhotoTypes map[PhotoVisibility]struct{}
|
||||||
cacheBlockedUserIDs []uint64
|
cacheBlockedUserIDs []uint64
|
||||||
cachePhotoIDs []uint64
|
cachePhotoIDs []uint64
|
||||||
|
|
||||||
|
// Feature mutexes.
|
||||||
|
muStatistic sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserVisibility string
|
type UserVisibility string
|
||||||
|
@ -226,6 +230,22 @@ func IsValidUsername(username string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PingLastLoginAt refreshes the user's "last logged in" time.
|
||||||
|
func (u *User) PingLastLoginAt() error {
|
||||||
|
// Also ping their daily active user statistic.
|
||||||
|
if err := LogDailyActiveUser(u); err != nil {
|
||||||
|
log.Error("PingLastLoginAt(%s): couldn't log daily active user statistic: %s", u.Username, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.LastLoginAt = time.Now()
|
||||||
|
return u.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBanned returns if the user account is banned.
|
||||||
|
func (u *User) IsBanned() bool {
|
||||||
|
return u.Status == UserStatusBanned
|
||||||
|
}
|
||||||
|
|
||||||
// IsShyFrom tells whether the user is shy from the perspective of the other user.
|
// IsShyFrom tells whether the user is shy from the perspective of the other user.
|
||||||
//
|
//
|
||||||
// That is, depending on our profile visibility and friendship status.
|
// That is, depending on our profile visibility and friendship status.
|
||||||
|
@ -271,7 +291,8 @@ func (u *User) CanBeSeenBy(viewer *User) error {
|
||||||
|
|
||||||
// UserSearch config.
|
// UserSearch config.
|
||||||
type UserSearch struct {
|
type UserSearch struct {
|
||||||
Username string
|
Username string // fuzzy search by name or username
|
||||||
|
InUsername []string // exact set of usernames (e.g. On Chat)
|
||||||
Gender string
|
Gender string
|
||||||
Orientation string
|
Orientation string
|
||||||
MaritalStatus string
|
MaritalStatus string
|
||||||
|
@ -353,6 +374,11 @@ func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, er
|
||||||
placeholders = append(placeholders, ilike, ilike)
|
placeholders = append(placeholders, ilike, ilike)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(search.InUsername) > 0 {
|
||||||
|
wheres = append(wheres, "users.username IN ?")
|
||||||
|
placeholders = append(placeholders, search.InUsername)
|
||||||
|
}
|
||||||
|
|
||||||
if search.Gender != "" {
|
if search.Gender != "" {
|
||||||
wheres = append(wheres, `
|
wheres = append(wheres, `
|
||||||
EXISTS (
|
EXISTS (
|
||||||
|
@ -590,6 +616,33 @@ func MapAdminUsers(user *User) (UserMap, error) {
|
||||||
return MapUsers(user, userIDs)
|
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?
|
// Has a user ID in the map?
|
||||||
func (um UserMap) Has(id uint64) bool {
|
func (um UserMap) Has(id uint64) bool {
|
||||||
_, ok := um[id]
|
_, ok := um[id]
|
||||||
|
@ -654,28 +707,6 @@ 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.
|
// 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.
|
// Returns a boolean (false if currentUser can't see) and the Visibility setting of the profile photo.
|
||||||
|
@ -760,6 +791,15 @@ 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.
|
// GetProfileField returns the value of a profile field or blank string.
|
||||||
func (u *User) GetProfileField(name string) string {
|
func (u *User) GetProfileField(name string) string {
|
||||||
for _, field := range u.ProfileField {
|
for _, field := range u.ProfileField {
|
||||||
|
|
|
@ -42,6 +42,8 @@ func SetUserRelationships(currentUser *User, users []*User) error {
|
||||||
|
|
||||||
// Inject the UserRelationships.
|
// Inject the UserRelationships.
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
|
u.UserRelationship.Computed = true
|
||||||
|
|
||||||
if u.ID == currentUser.ID {
|
if u.ID == currentUser.ID {
|
||||||
// Current user - set both bools to true - you can always see your own profile pic.
|
// Current user - set both bools to true - you can always see your own profile pic.
|
||||||
u.UserRelationship.IsFriend = true
|
u.UserRelationship.IsFriend = true
|
||||||
|
|
118
pkg/photo/photosign.go
Normal file
118
pkg/photo/photosign.go
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -34,6 +34,7 @@ func New() http.Handler {
|
||||||
mux.HandleFunc("GET /sw.js", index.ServiceWorker())
|
mux.HandleFunc("GET /sw.js", index.ServiceWorker())
|
||||||
mux.HandleFunc("GET /about", index.StaticTemplate("about.html")())
|
mux.HandleFunc("GET /about", index.StaticTemplate("about.html")())
|
||||||
mux.HandleFunc("GET /features", index.StaticTemplate("features.html")())
|
mux.HandleFunc("GET /features", index.StaticTemplate("features.html")())
|
||||||
|
mux.HandleFunc("GET /insights", index.Demographics())
|
||||||
mux.HandleFunc("GET /faq", index.StaticTemplate("faq.html")())
|
mux.HandleFunc("GET /faq", index.StaticTemplate("faq.html")())
|
||||||
mux.HandleFunc("GET /tos", index.StaticTemplate("tos.html")())
|
mux.HandleFunc("GET /tos", index.StaticTemplate("tos.html")())
|
||||||
mux.HandleFunc("GET /privacy", index.StaticTemplate("privacy.html")())
|
mux.HandleFunc("GET /privacy", index.StaticTemplate("privacy.html")())
|
||||||
|
@ -62,6 +63,7 @@ func New() http.Handler {
|
||||||
mux.Handle("GET /photo/view", middleware.LoginRequired(photo.View()))
|
mux.Handle("GET /photo/view", middleware.LoginRequired(photo.View()))
|
||||||
mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit()))
|
mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit()))
|
||||||
mux.Handle("/photo/delete", middleware.LoginRequired(photo.Delete()))
|
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("/photo/certification", middleware.LoginRequired(photo.Certification()))
|
||||||
mux.Handle("GET /photo/private", middleware.LoginRequired(photo.Private()))
|
mux.Handle("GET /photo/private", middleware.LoginRequired(photo.Private()))
|
||||||
mux.Handle("/photo/private/share", middleware.LoginRequired(photo.Share()))
|
mux.Handle("/photo/private/share", middleware.LoginRequired(photo.Share()))
|
||||||
|
@ -111,6 +113,7 @@ func New() http.Handler {
|
||||||
|
|
||||||
// JSON API endpoints.
|
// JSON API endpoints.
|
||||||
mux.HandleFunc("GET /v1/version", api.Version())
|
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("GET /v1/users/me", api.LoginOK())
|
||||||
mux.HandleFunc("POST /v1/users/check-username", api.UsernameCheck())
|
mux.HandleFunc("POST /v1/users/check-username", api.UsernameCheck())
|
||||||
mux.HandleFunc("GET /v1/web-push/vapid-public-key", webpush.VAPIDPublicKey)
|
mux.HandleFunc("GET /v1/web-push/vapid-public-key", webpush.VAPIDPublicKey)
|
||||||
|
@ -118,6 +121,7 @@ func New() http.Handler {
|
||||||
mux.Handle("GET /v1/web-push/unregister", middleware.LoginRequired(webpush.UnregisterAll()))
|
mux.Handle("GET /v1/web-push/unregister", middleware.LoginRequired(webpush.UnregisterAll()))
|
||||||
mux.Handle("POST /v1/likes", middleware.LoginRequired(api.Likes()))
|
mux.Handle("POST /v1/likes", middleware.LoginRequired(api.Likes()))
|
||||||
mux.Handle("GET /v1/likes/users", middleware.LoginRequired(api.WhoLikes()))
|
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/read", middleware.LoginRequired(api.ReadNotification()))
|
||||||
mux.Handle("POST /v1/notifications/delete", middleware.LoginRequired(api.ClearNotification()))
|
mux.Handle("POST /v1/notifications/delete", middleware.LoginRequired(api.ClearNotification()))
|
||||||
mux.Handle("POST /v1/photos/mark-explicit", middleware.LoginRequired(api.MarkPhotoExplicit()))
|
mux.Handle("POST /v1/photos/mark-explicit", middleware.LoginRequired(api.MarkPhotoExplicit()))
|
||||||
|
|
|
@ -172,8 +172,7 @@ func LoginUser(w http.ResponseWriter, r *http.Request, u *models.User) error {
|
||||||
sess.Save(w)
|
sess.Save(w)
|
||||||
|
|
||||||
// Ping the user's last login time.
|
// Ping the user's last login time.
|
||||||
u.LastLoginAt = time.Now()
|
return u.PingLastLoginAt()
|
||||||
return u.Save()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImpersonateUser assumes the role of the user impersonated by an admin uid.
|
// ImpersonateUser assumes the role of the user impersonated by an admin uid.
|
||||||
|
|
|
@ -38,7 +38,8 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
||||||
"ToMarkdown": ToMarkdown,
|
"ToMarkdown": ToMarkdown,
|
||||||
"ToJSON": ToJSON,
|
"ToJSON": ToJSON,
|
||||||
"ToHTML": ToHTML,
|
"ToHTML": ToHTML,
|
||||||
"PhotoURL": photo.URLPath,
|
"PhotoURL": PhotoURL(r),
|
||||||
|
"VisibleAvatarURL": photo.VisibleAvatarURL,
|
||||||
"Now": time.Now,
|
"Now": time.Now,
|
||||||
"RunTime": RunTime,
|
"RunTime": RunTime,
|
||||||
"PrettyTitle": func() template.HTML {
|
"PrettyTitle": func() template.HTML {
|
||||||
|
@ -70,6 +71,14 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
||||||
|
|
||||||
// Get a description for an admin scope (e.g. for transparency page).
|
// Get a description for an admin scope (e.g. for transparency page).
|
||||||
"AdminScopeDescription": config.AdminScopeDescription,
|
"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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,6 +107,19 @@ func RunTime(r *http.Request) string {
|
||||||
return "ERROR"
|
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.
|
// 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 {
|
func BlurExplicit(r *http.Request) func(*models.Photo) bool {
|
||||||
return func(photo *models.Photo) bool {
|
return func(photo *models.Photo) bool {
|
||||||
|
|
15
pkg/utility/enum.go
Normal file
15
pkg/utility/enum.go
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -6,6 +6,17 @@ import (
|
||||||
"strings"
|
"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).
|
// 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 {
|
func FormatNumberShort(value int64) string {
|
||||||
// Under 1,000?
|
// Under 1,000?
|
||||||
|
@ -20,21 +31,13 @@ func FormatNumberShort(value int64) string {
|
||||||
billions = float64(millions) / 1000
|
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 {
|
if thousands < 1000 {
|
||||||
return fmt.Sprintf("%sK", formatFloat(thousands))
|
return fmt.Sprintf("%sK", FormatFloatToPrecision(thousands, 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
if millions < 1000 {
|
if millions < 1000 {
|
||||||
return fmt.Sprintf("%sM", formatFloat(millions))
|
return fmt.Sprintf("%sM", FormatFloatToPrecision(millions, 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%sB", formatFloat(billions))
|
return fmt.Sprintf("%sB", FormatFloatToPrecision(billions, 1))
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,22 @@ import (
|
||||||
"time"
|
"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.
|
// FormatDurationCoarse returns a pretty printed duration with coarse granularity.
|
||||||
func FormatDurationCoarse(duration time.Duration) string {
|
func FormatDurationCoarse(duration time.Duration) string {
|
||||||
// Negative durations (e.g. future dates) should work too.
|
// Negative durations (e.g. future dates) should work too.
|
||||||
|
|
|
@ -7,6 +7,53 @@ import (
|
||||||
"code.nonshy.com/nonshy/website/pkg/utility"
|
"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) {
|
func TestFormatDurationCoarse(t *testing.T) {
|
||||||
var tests = []struct {
|
var tests = []struct {
|
||||||
In time.Duration
|
In time.Duration
|
||||||
|
|
|
@ -30,6 +30,14 @@
|
||||||
color: rgb(26, 0, 5) !important;
|
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)
|
/* 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) */
|
to show as a bright bulma is-warning style (.tag.is-warning) */
|
||||||
.nonshy-navbar-notification {
|
.nonshy-navbar-notification {
|
||||||
|
@ -37,6 +45,11 @@
|
||||||
color: rgb(26, 0, 5) !important;
|
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 {
|
.has-text-dark {
|
||||||
/* note: this css file otherwise didn't override this, dark's always dark, brighten it! */
|
/* note: this css file otherwise didn't override this, dark's always dark, brighten it! */
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
/* Custom nonshy color overrides for Bulma's dark theme
|
/* Custom nonshy color overrides for Bulma's dark theme
|
||||||
(prefers-dark edition) */
|
(prefers-dark edition) */
|
||||||
@import url("dark-theme.css") screen and (prefers-color-scheme: dark);
|
@import url("dark-theme.css?nocache=2") screen and (prefers-color-scheme: dark);
|
8
web/static/css/theme-blue-pink.css
Normal file
8
web/static/css/theme-blue-pink.css
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
: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%;
|
||||||
|
}
|
7
web/static/css/theme-blue.css
Normal file
7
web/static/css/theme-blue.css
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
:root {
|
||||||
|
--bulma-primary-h: 204deg;
|
||||||
|
--bulma-primary-l: 60%;
|
||||||
|
--bulma-link-h: 200deg;
|
||||||
|
--bulma-link-l: 34%;
|
||||||
|
--bulma-scheme-h: 173;
|
||||||
|
}
|
7
web/static/css/theme-green.css
Normal file
7
web/static/css/theme-green.css
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
:root {
|
||||||
|
--bulma-primary-h: 99deg;
|
||||||
|
--bulma-link-h: 112deg;
|
||||||
|
--bulma-link-l: 18%;
|
||||||
|
--bulma-scheme-h: 95;
|
||||||
|
--bulma-link-text: #009900;
|
||||||
|
}
|
8
web/static/css/theme-pink.css
Normal file
8
web/static/css/theme-pink.css
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
:root {
|
||||||
|
--bulma-primary-h: 300deg;
|
||||||
|
--bulma-primary-l: 80%;
|
||||||
|
--bulma-link-h: 293deg;
|
||||||
|
--bulma-scheme-h: 295;
|
||||||
|
--bulma-scheme-s: 39%;
|
||||||
|
}
|
||||||
|
|
8
web/static/css/theme-purple.css
Normal file
8
web/static/css/theme-purple.css
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
: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%;
|
||||||
|
}
|
7
web/static/css/theme-red.css
Normal file
7
web/static/css/theme-red.css
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
:root {
|
||||||
|
--bulma-primary-h: 15deg;
|
||||||
|
--bulma-primary-l: 63%;
|
||||||
|
--bulma-link-h: 12deg;
|
||||||
|
--bulma-link-l: 30%;
|
||||||
|
--bulma-scheme-h: 0;
|
||||||
|
}
|
|
@ -1,5 +1,11 @@
|
||||||
/* Custom CSS styles */
|
/* 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 {
|
abbr {
|
||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
@ -16,6 +22,10 @@ abbr {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.has-text-smaller {
|
||||||
|
font-size: smaller;
|
||||||
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
/* https://stackoverflow.com/questions/12906789/preventing-an-image-from-being-draggable-or-selectable-without-using-js */
|
/* https://stackoverflow.com/questions/12906789/preventing-an-image-from-being-draggable-or-selectable-without-using-js */
|
||||||
user-drag: none;
|
user-drag: none;
|
||||||
|
@ -96,12 +106,18 @@ img {
|
||||||
|
|
||||||
/* Mobile: notification badge near the hamburger menu */
|
/* Mobile: notification badge near the hamburger menu */
|
||||||
.nonshy-mobile-notification {
|
.nonshy-mobile-notification {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
right: 50px;
|
right: 50px;
|
||||||
z-index: 1000;
|
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 */
|
/* PWA: loading indicator in the corner of the page */
|
||||||
#nonshy-pwa-loader {
|
#nonshy-pwa-loader {
|
||||||
display: none;
|
display: none;
|
||||||
|
@ -149,6 +165,12 @@ img {
|
||||||
height: 1.5em !important;
|
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.
|
* Mobile navbar notification count badge no.
|
||||||
*/
|
*/
|
||||||
|
|
BIN
web/static/img/shybot.jpg
Normal file
BIN
web/static/img/shybot.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 93 KiB |
|
@ -8,13 +8,20 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
// at the page header instead of going to the dedicated comment page.
|
// at the page header instead of going to the dedicated comment page.
|
||||||
(document.querySelectorAll(".nonshy-quote-button") || []).forEach(node => {
|
(document.querySelectorAll(".nonshy-quote-button") || []).forEach(node => {
|
||||||
const message = node.dataset.quoteBody,
|
const message = node.dataset.quoteBody,
|
||||||
replyTo = node.dataset.replyTo;
|
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})`;
|
||||||
|
}
|
||||||
|
|
||||||
node.addEventListener("click", (e) => {
|
node.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (replyTo) {
|
if (replyTo) {
|
||||||
$message.value += "@" + replyTo + "\n\n";
|
$message.value += atMention + "\n\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare the quoted message.
|
// Prepare the quoted message.
|
||||||
|
@ -30,11 +37,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
});
|
});
|
||||||
|
|
||||||
(document.querySelectorAll(".nonshy-reply-button") || []).forEach(node => {
|
(document.querySelectorAll(".nonshy-reply-button") || []).forEach(node => {
|
||||||
const replyTo = node.dataset.replyTo;
|
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})`;
|
||||||
|
}
|
||||||
|
|
||||||
node.addEventListener("click", (e) => {
|
node.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
$message.value += "@" + replyTo + "\n\n";
|
$message.value += atMention + "\n\n";
|
||||||
$message.scrollIntoView();
|
$message.scrollIntoView();
|
||||||
$message.focus();
|
$message.focus();
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,8 +4,13 @@
|
||||||
<section class="hero is-link is-bold">
|
<section class="hero is-link is-bold">
|
||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="title">User Dashboard</h1>
|
<h1 class="title">
|
||||||
<h2 class="subtitle">to your account</h2>
|
<span class="icon mr-4 pl-3">
|
||||||
|
<i class="fa fa-house-user"></i>
|
||||||
|
</span>
|
||||||
|
<span>My Dashboard</span>
|
||||||
|
</h1>
|
||||||
|
<h2 class="subtitle">Settings & Notifications</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
@ -182,7 +187,7 @@
|
||||||
|
|
||||||
<div class="card block">
|
<div class="card block">
|
||||||
<header class="card-header has-background-link">
|
<header class="card-header has-background-link">
|
||||||
<p class="card-header-title has-text-light">My Account</p>
|
<p class="card-header-title has-text-light">Quick Links</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
|
@ -203,7 +208,7 @@
|
||||||
<li>
|
<li>
|
||||||
<a href="/photo/upload">
|
<a href="/photo/upload">
|
||||||
<span class="icon"><i class="fa fa-upload"></i></span>
|
<span class="icon"><i class="fa fa-upload"></i></span>
|
||||||
Upload Photos
|
Upload Photo
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
@ -214,28 +219,28 @@
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/settings">
|
<a href="/settings">
|
||||||
<span class="icon"><i class="fa fa-edit"></i></span>
|
<span class="icon"><i class="fa fa-gear"></i></span>
|
||||||
Edit Profile & Settings
|
Edit Profile & Settings
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<a href="/photo/certification">
|
|
||||||
<span class="icon"><i class="fa fa-certificate"></i></span>
|
|
||||||
Certification Photo
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="/users/blocked">
|
|
||||||
<span class="icon"><i class="fa fa-hand"></i></span>
|
|
||||||
Blocked Users
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<a href="/notes/me">
|
<a href="/notes/me">
|
||||||
<span class="icon"><i class="fa fa-pen-to-square mr-1"></i></span>
|
<span class="icon"><i class="fa fa-pen-to-square mr-1"></i></span>
|
||||||
My User Notes
|
My User Notes
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/photo/certification">
|
||||||
|
<span class="icon"><i class="fa fa-certificate"></i></span>
|
||||||
|
My Certification Photo
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/users/blocked">
|
||||||
|
<span class="icon"><i class="fa fa-hand"></i></span>
|
||||||
|
Blocked Users
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/logout">
|
<a href="/logout">
|
||||||
<span class="icon"><i class="fa fa-arrow-right-from-bracket"></i></span>
|
<span class="icon"><i class="fa fa-arrow-right-from-bracket"></i></span>
|
||||||
|
@ -250,12 +255,6 @@
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
<li>
|
|
||||||
<a href="/settings#deactivate">
|
|
||||||
<span class="icon"><i class="fa fa-trash"></i></span>
|
|
||||||
Delete account
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
@ -487,9 +486,7 @@
|
||||||
<strong class="tag is-success">NEW!</strong>
|
<strong class="tag is-success">NEW!</strong>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<a href="/u/{{.AboutUser.Username}}">
|
|
||||||
{{template "avatar-48x48" .AboutUser}}
|
{{template "avatar-48x48" .AboutUser}}
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div class="mb-1 pr-4">
|
<div class="mb-1 pr-4">
|
||||||
|
@ -600,6 +597,11 @@
|
||||||
You have been appointed as a <strong class="has-text-success">moderator</strong>
|
You have been appointed as a <strong class="has-text-success">moderator</strong>
|
||||||
for the forum <a href="/f/{{$Body.Forum.Fragment}}">{{$Body.Forum.Title}}</a>!
|
for the forum <a href="/f/{{$Body.Forum.Fragment}}">{{$Body.Forum.Title}}</a>!
|
||||||
</span>
|
</span>
|
||||||
|
{{else if eq .Type "explicit_photo"}}
|
||||||
|
<span class="icon"><i class="fa fa-fire has-text-danger"></i></span>
|
||||||
|
<span>
|
||||||
|
Your <a href="{{.Link}}">photo</a> was marked as <span class="has-text-danger">Explicit!</span>
|
||||||
|
</span>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{.AboutUser.Username}} {{.Type}} {{.TableName}} {{.TableID}}
|
{{.AboutUser.Username}} {{.Type}} {{.TableName}} {{.TableID}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
@ -628,6 +630,18 @@
|
||||||
<span class="icon"><i class="fa fa-arrow-right"></i></span>
|
<span class="icon"><i class="fa fa-arrow-right"></i></span>
|
||||||
<a href="{{.Link}}">See all comments</a>
|
<a href="{{.Link}}">See all comments</a>
|
||||||
</div>
|
</div>
|
||||||
|
{{else if eq .Type "explicit_photo"}}
|
||||||
|
<div class="content pt-1" style="font-size: smaller">
|
||||||
|
<p>
|
||||||
|
A community member thinks that this photo should have been marked as 'Explicit' when
|
||||||
|
it was uploaded.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Please <a href="/tos#explicit-photos">review our Explicit Photos policy</a>
|
||||||
|
and remember to correctly mark your new uploads as 'explicit' when they contain sexually
|
||||||
|
suggestive content.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<em>{{or $Body.Photo.Caption "No caption."}}</em>
|
<em>{{or $Body.Photo.Caption "No caption."}}</em>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
@ -2,16 +2,18 @@
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{template "profile-theme-style" .User}}
|
{{template "profile-theme-style" .User}}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<section class="hero {{if and .LoggedIn (not .IsPrivate)}}is-info{{else}}is-light is-bold{{end}}">
|
<section class="hero {{if and .LoggedIn (not .IsPrivate)}}is-info{{else}}is-light is-bold{{end}} {{if not .IsExternalView}}user-theme-hero{{end}}">
|
||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-narrow has-text-centered">
|
<div class="column is-narrow has-text-centered">
|
||||||
<figure class="profile-photo is-inline-block">
|
<figure class="profile-photo is-inline-block">
|
||||||
{{if or (not .CurrentUser) .IsExternalView}}
|
{{if or (not .CurrentUser) .IsExternalView}}
|
||||||
<img src="{{.User.VisibleAvatarURL nil}}" data-photo-id="{{.User.ProfilePhoto.ID}}">
|
<img src="{{VisibleAvatarURL .User nil}}" data-photo-id="{{.User.ProfilePhoto.ID}}">
|
||||||
{{else}}
|
{{else}}
|
||||||
<img src="{{.User.VisibleAvatarURL .CurrentUser}}" data-photo-id="{{.User.ProfilePhoto.ID}}">
|
<a href="/u/{{.User.Username}}/photos">
|
||||||
|
<img src="{{VisibleAvatarURL .User .CurrentUser}}" data-photo-id="{{.User.ProfilePhoto.ID}}">
|
||||||
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- CurrentUser can upload a new profile pic -->
|
<!-- CurrentUser can upload a new profile pic -->
|
||||||
|
@ -311,43 +313,76 @@
|
||||||
|
|
||||||
<div class="column is-two-thirds">
|
<div class="column is-two-thirds">
|
||||||
<div class="card block">
|
<div class="card block">
|
||||||
<header class="card-header has-background-link">
|
<header class="card-header has-background-link user-theme-card-title">
|
||||||
<p class="card-header-title has-text-light">
|
<div class="card-header-title">
|
||||||
|
<div class="columns is-mobile is-gapless nonshy-fullwidth">
|
||||||
|
<div class="column">
|
||||||
About Me
|
About Me
|
||||||
</p>
|
</div>
|
||||||
|
{{if eq .CurrentUser.ID .User.ID}}
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<a href="/settings#profile/about_me" class="button is-outlined is-small">
|
||||||
|
<i class="fa fa-pencil"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content user-theme-card-body">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{or (ToMarkdown (.User.GetProfileField "about_me")) "n/a"}}
|
{{or (ReSignPhotoLinks (ToMarkdown (.User.GetProfileField "about_me"))) "n/a"}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card block">
|
<div class="card block">
|
||||||
<header class="card-header has-background-link">
|
<header class="card-header has-background-link user-theme-card-title">
|
||||||
<p class="card-header-title has-text-light">
|
<div class="card-header-title">
|
||||||
|
<div class="columns is-mobile is-gapless nonshy-fullwidth">
|
||||||
|
<div class="column">
|
||||||
My Interests
|
My Interests
|
||||||
</p>
|
</div>
|
||||||
|
{{if eq .CurrentUser.ID .User.ID}}
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<a href="/settings#profile/interests" class="button is-outlined is-small">
|
||||||
|
<i class="fa fa-pencil"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content user-theme-card-body">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{or (ToMarkdown (.User.GetProfileField "interests")) "n/a"}}
|
{{or (ReSignPhotoLinks (ToMarkdown (.User.GetProfileField "interests"))) "n/a"}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card block">
|
<div class="card block">
|
||||||
<header class="card-header has-background-link">
|
<header class="card-header has-background-link user-theme-card-title">
|
||||||
<p class="card-header-title has-text-light">
|
<div class="card-header-title">
|
||||||
|
<div class="columns is-mobile is-gapless nonshy-fullwidth">
|
||||||
|
<div class="column">
|
||||||
Music/Bands/Movies
|
Music/Bands/Movies
|
||||||
</p>
|
</div>
|
||||||
|
{{if eq .CurrentUser.ID .User.ID}}
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<a href="/settings#profile/music_movies" class="button is-outlined is-small">
|
||||||
|
<i class="fa fa-pencil"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content user-theme-card-body">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{or (ToMarkdown (.User.GetProfileField "music_movies")) "n/a"}}
|
{{or (ReSignPhotoLinks (ToMarkdown (.User.GetProfileField "music_movies"))) "n/a"}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -355,14 +390,14 @@
|
||||||
|
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div class="card block">
|
<div class="card block">
|
||||||
<header class="card-header has-background-info">
|
<header class="card-header has-background-info user-theme-card-title">
|
||||||
<p class="card-header-title has-text-light">
|
<p class="card-header-title has-text-light">
|
||||||
<i class="fa fa-user pr-2"></i>
|
<i class="fa fa-user pr-2"></i>
|
||||||
About {{.User.Username}}
|
About {{.User.Username}}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content user-theme-card-body">
|
||||||
<table class="table is-fullwidth" style="font-size: small">
|
<table class="table is-fullwidth" style="font-size: small">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="has-text-right">
|
<td class="has-text-right">
|
||||||
|
@ -441,14 +476,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card block">
|
<div class="card block">
|
||||||
<header class="card-header has-background-info">
|
<header class="card-header has-background-info user-theme-card-title">
|
||||||
<p class="card-header-title has-text-light">
|
<p class="card-header-title has-text-light">
|
||||||
<i class="fa fa-chart-line pr-2"></i>
|
<i class="fa fa-chart-line pr-2"></i>
|
||||||
Activity
|
Activity
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content user-theme-card-body">
|
||||||
|
|
||||||
<!-- Lazy load the statistics card-->
|
<!-- Lazy load the statistics card-->
|
||||||
<div hx-get="/htmx/user/profile/activity?username={{.User.Username}}" hx-trigger="load">
|
<div hx-get="/htmx/user/profile/activity?username={{.User.Username}}" hx-trigger="load">
|
||||||
|
@ -492,6 +527,17 @@
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<p class="menu-label">Admin Actions</p>
|
<p class="menu-label">Admin Actions</p>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/user-action?intent=chat.rules&user_id={{.User.ID}}">
|
||||||
|
<span class="icon"><i class="fa fa-gavel"></i></span>
|
||||||
|
<span>
|
||||||
|
Chat Moderation Rules
|
||||||
|
{{if .NumChatModerationRules}}
|
||||||
|
<span class="tag is-warning ml-2">{{.NumChatModerationRules}}</span>
|
||||||
|
{{end}}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/admin/user-action?intent=essays&user_id={{.User.ID}}">
|
<a href="/admin/user-action?intent=essays&user_id={{.User.ID}}">
|
||||||
<span class="icon"><i class="fa fa-pencil"></i></span>
|
<span class="icon"><i class="fa fa-pencil"></i></span>
|
||||||
|
|
|
@ -82,6 +82,15 @@
|
||||||
<a href="/faq#shy-faqs" target="_blank">Learn more <i class="fa fa-external-link"></i></a>
|
<a href="/faq#shy-faqs" target="_blank">Learn more <i class="fa fa-external-link"></i></a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="block">
|
||||||
|
<i class="fa fa-hand-point-right mr-1"></i>
|
||||||
|
<strong>See also:</strong>
|
||||||
|
<a href="/members?sort=distance">
|
||||||
|
<i class="fa fa-location-dot mx-1"></i>
|
||||||
|
Who's Nearby?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- Restricted search terms -->
|
<!-- Restricted search terms -->
|
||||||
|
@ -100,11 +109,17 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
It is also against the <a href="/tos#child-exploitation">Terms of Service</a> of this website, and
|
It is also against the <a href="/tos#child-exploitation">Terms of Service</a> of this website, and
|
||||||
members who violate this rule will be banned. This website is actively monitored to keep on top of this stuff,
|
members who violate this rule will be banned. <strong>This website is actively monitored</strong> to keep on top of this stuff,
|
||||||
and we cooperate enthusiastically with
|
and we cooperate enthusiastically with
|
||||||
<a href="https://www.missingkids.org/" title="National Center for Missing and Exploited Children">NCMEC</a>
|
<a href="https://www.missingkids.org/" title="National Center for Missing and Exploited Children">NCMEC</a>
|
||||||
and relevant law enforcement agencies.
|
and relevant law enforcement agencies.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This incident has been reported to the website administrator. If you are surprised to see this message and
|
||||||
|
it was on accident, don't worry -- but <strong>repeated attempts to bypass this search filter</strong> by trying
|
||||||
|
other related keywords <strong>will be noticed</strong> and may attract extra admin attention to your account.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
@ -178,7 +193,7 @@
|
||||||
|
|
||||||
<div class="column pl-1">
|
<div class="column pl-1">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="wcs">Location: <span class="tag is-success">New!</span></label>
|
<label class="label" for="wcs">Location:</label>
|
||||||
<input type="text" class="input"
|
<input type="text" class="input"
|
||||||
name="wcs" id="wcs"
|
name="wcs" id="wcs"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
@ -295,6 +310,14 @@
|
||||||
{{if .LikedSearch}}checked{{end}}>
|
{{if .LikedSearch}}checked{{end}}>
|
||||||
Show only my "Likes"
|
Show only my "Likes"
|
||||||
</label>
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="on_chat"
|
||||||
|
id="on_chat"
|
||||||
|
value="true"
|
||||||
|
{{if .OnChatSearch}}checked{{end}}>
|
||||||
|
Currently on chat <span class="tag is-success">New!</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -430,6 +453,7 @@
|
||||||
{{if .GetProfileField "orientation"}}
|
{{if .GetProfileField "orientation"}}
|
||||||
<span class="mr-2">{{.GetProfileField "orientation"}}</span>
|
<span class="mr-2">{{.GetProfileField "orientation"}}</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Chat room status -->
|
<!-- Chat room status -->
|
||||||
{{if $Root.UserOnChatMap.Get .Username}}
|
{{if $Root.UserOnChatMap.Get .Username}}
|
||||||
|
@ -470,7 +494,7 @@
|
||||||
{{$Root.DistanceMap.Get .ID}} away
|
{{$Root.DistanceMap.Get .ID}} away
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div><!-- media-block -->
|
</div><!-- media-block -->
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -307,7 +307,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field" id="profile/about_me">
|
||||||
<label class="label" for="about_me">About Me</label>
|
<label class="label" for="about_me">About Me</label>
|
||||||
<textarea class="textarea" cols="60" rows="4"
|
<textarea class="textarea" cols="60" rows="4"
|
||||||
id="about_me"
|
id="about_me"
|
||||||
|
@ -318,7 +318,7 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field" id="profile/interests">
|
||||||
<label class="label" for="interests">My Interests</label>
|
<label class="label" for="interests">My Interests</label>
|
||||||
<textarea class="textarea" cols="60" rows="4"
|
<textarea class="textarea" cols="60" rows="4"
|
||||||
id="interests"
|
id="interests"
|
||||||
|
@ -326,7 +326,7 @@
|
||||||
placeholder="What kinds of things make you curious?">{{$User.GetProfileField "interests"}}</textarea>
|
placeholder="What kinds of things make you curious?">{{$User.GetProfileField "interests"}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field" id="profile/music_movies">
|
||||||
<label class="label" for="music_movies">Music/Bands/Movies</label>
|
<label class="label" for="music_movies">Music/Bands/Movies</label>
|
||||||
<textarea class="textarea" cols="60" rows="4"
|
<textarea class="textarea" cols="60" rows="4"
|
||||||
id="music_movies"
|
id="music_movies"
|
||||||
|
@ -402,6 +402,26 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="website-theme-hue">
|
||||||
|
Accent color <span class="tag is-success">New!</span>
|
||||||
|
</label>
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
<select name="website-theme-hue">
|
||||||
|
{{range .WebsiteThemeHueChoices}}
|
||||||
|
<option value="{{.Value}}"
|
||||||
|
{{if eq ($User.GetProfileField "website-theme-hue") .Value}}selected{{end}}>
|
||||||
|
{{.Label}}
|
||||||
|
</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="help">
|
||||||
|
Select an "accent color" for the website theme. Mix and match these with
|
||||||
|
the Light and Dark themes! Some accent colors really pop on the dark theme.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
@ -835,7 +855,7 @@
|
||||||
logged-out browser, they are prompted to log in.
|
logged-out browser, they are prompted to log in.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label class="checkbox mt-2">
|
<label class="checkbox mt-3">
|
||||||
<input type="radio"
|
<input type="radio"
|
||||||
name="visibility"
|
name="visibility"
|
||||||
value="external"
|
value="external"
|
||||||
|
@ -850,7 +870,7 @@
|
||||||
<a href="/u/{{.CurrentUser.Username}}?view=external" target="_blank">Preview <i class="fa fa-external-link"></i></a>
|
<a href="/u/{{.CurrentUser.Username}}?view=external" target="_blank">Preview <i class="fa fa-external-link"></i></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label class="checkbox mt-2">
|
<label class="checkbox mt-3">
|
||||||
<input type="radio"
|
<input type="radio"
|
||||||
name="visibility"
|
name="visibility"
|
||||||
value="private"
|
value="private"
|
||||||
|
@ -871,15 +891,14 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label mb-0">Who can send me the first <i class="fa fa-envelope"></i> Message?</label>
|
<label class="label mb-0">Who can send me the first <i class="fa fa-envelope"></i> Message?</label>
|
||||||
|
|
||||||
<div class="has-text-info ml-4">
|
<div class="has-text-info">
|
||||||
<small><em>
|
<small><em>
|
||||||
Note: this refers to Direct Messages on the main website
|
Note: this refers to Direct Messages on the main website
|
||||||
(not inside the chat room).
|
(not inside the chat room).
|
||||||
</em></small>
|
</em></small>
|
||||||
{{.CurrentUser.GetProfileField "dm_privacy"}}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="checkbox">
|
<label class="checkbox mt-3">
|
||||||
<input type="radio"
|
<input type="radio"
|
||||||
name="dm_privacy"
|
name="dm_privacy"
|
||||||
value=""
|
value=""
|
||||||
|
@ -891,24 +910,26 @@
|
||||||
page (except for maybe <a href="/faq#shy-faqs" target="_blank">Shy Accounts</a>).
|
page (except for maybe <a href="/faq#shy-faqs" target="_blank">Shy Accounts</a>).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label class="checkbox">
|
<label class="checkbox mt-3">
|
||||||
<input type="radio"
|
<input type="radio"
|
||||||
name="dm_privacy"
|
name="dm_privacy"
|
||||||
value="friends"
|
value="friends"
|
||||||
{{if eq (.CurrentUser.GetProfileField "dm_privacy") "friends"}}checked{{end}}>
|
{{if eq (.CurrentUser.GetProfileField "dm_privacy") "friends"}}checked{{end}}>
|
||||||
Only people on my Friends list
|
Only people on my Friends list
|
||||||
|
<i class="fa fa-user-group has-text-warning ml-2"></i>
|
||||||
</label>
|
</label>
|
||||||
<p class="help">
|
<p class="help">
|
||||||
Nobody can slide into your DMs except for friends (and admins if needed). Anybody
|
Nobody can slide into your DMs except for friends (and admins if needed). Anybody
|
||||||
may <em>reply</em> to messages that you send to them.
|
may <em>reply</em> to messages that you send to them.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label class="checkbox">
|
<label class="checkbox mt-3">
|
||||||
<input type="radio"
|
<input type="radio"
|
||||||
name="dm_privacy"
|
name="dm_privacy"
|
||||||
value="nobody"
|
value="nobody"
|
||||||
{{if eq (.CurrentUser.GetProfileField "dm_privacy") "nobody"}}checked{{end}}>
|
{{if eq (.CurrentUser.GetProfileField "dm_privacy") "nobody"}}checked{{end}}>
|
||||||
Nobody (close my DMs)
|
Nobody (close my DMs)
|
||||||
|
<i class="fa fa-hand has-text-danger ml-2"></i>
|
||||||
</label>
|
</label>
|
||||||
<p class="help">
|
<p class="help">
|
||||||
Nobody can start a Direct Message conversation with you on the main website
|
Nobody can start a Direct Message conversation with you on the main website
|
||||||
|
@ -919,6 +940,77 @@
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label mb-0">
|
||||||
|
Who can share their
|
||||||
|
<span class="has-text-private">
|
||||||
|
<i class="fa fa-eye"></i> Private Photos
|
||||||
|
</span>
|
||||||
|
with me?
|
||||||
|
<span class="tag is-success">New!</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p class="help">
|
||||||
|
This setting can help you to be in control of who else on {{PrettyTitle}} is allowed
|
||||||
|
to unlock their private photo gallery for you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="checkbox mt-3">
|
||||||
|
<input type="radio"
|
||||||
|
name="private_photo_gate"
|
||||||
|
value=""
|
||||||
|
{{if eq (.CurrentUser.GetProfileField "private_photo_gate") ""}}checked{{end}}>
|
||||||
|
Anybody on the site
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
Any member of the website is able to share their private photo gallery with you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="checkbox mt-3">
|
||||||
|
<input type="radio"
|
||||||
|
name="private_photo_gate"
|
||||||
|
value="friends"
|
||||||
|
{{if eq (.CurrentUser.GetProfileField "private_photo_gate") "friends"}}checked{{end}}>
|
||||||
|
Only people on my Friends list
|
||||||
|
<i class="fa fa-user-group has-text-warning ml-2"></i>
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
Only people who you have accepted as a friend will have the ability to share their private
|
||||||
|
photo gallery with you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="checkbox mt-3">
|
||||||
|
<input type="radio"
|
||||||
|
name="private_photo_gate"
|
||||||
|
value="messaged"
|
||||||
|
{{if eq (.CurrentUser.GetProfileField "private_photo_gate") "messaged"}}checked{{end}}>
|
||||||
|
Only my friends and people I have sent a DM to
|
||||||
|
<i class="fa fa-user-group has-text-warning ml-2"></i>
|
||||||
|
<i class="fa fa-envelope has-text-link"></i>
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
People on your friend list and people who <strong>you</strong> have sent a Direct Message to
|
||||||
|
(on the main website - not the chat room) will be able to share their private photos with you.
|
||||||
|
Note: for example, if somebody sends <em>you</em> an unsolicited DM and you did not respond,
|
||||||
|
that person can not share their private photos with you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="checkbox mt-3">
|
||||||
|
<input type="radio"
|
||||||
|
name="private_photo_gate"
|
||||||
|
value="nobody"
|
||||||
|
{{if eq (.CurrentUser.GetProfileField "private_photo_gate") "nobody"}}checked{{end}}>
|
||||||
|
Nobody <i class="fa fa-hand has-text-danger ml-2"></i>
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
Nobody on the website will be allowed to share their private gallery with you. Note: this
|
||||||
|
will mean that you will have no method to see private photos on the site except those which
|
||||||
|
had already been shared with you in the past.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<button type="submit" class="button is-primary">
|
<button type="submit" class="button is-primary">
|
||||||
<i class="fa fa-save mr-2"></i> Save Privacy Settings
|
<i class="fa fa-save mr-2"></i> Save Privacy Settings
|
||||||
|
@ -1421,9 +1513,10 @@ window.addEventListener("DOMContentLoaded", (event) => {
|
||||||
// Global function to toggle the active tab.
|
// Global function to toggle the active tab.
|
||||||
const showTab = (name) => {
|
const showTab = (name) => {
|
||||||
name = name.replace(/\.$/, '');
|
name = name.replace(/\.$/, '');
|
||||||
if (!name) name = "profile";
|
let tabName = name.split('/')[0]; // "#profile/about_me"
|
||||||
|
if (!tabName) name = "profile";
|
||||||
$activeTab.style.display = 'none';
|
$activeTab.style.display = 'none';
|
||||||
switch (name) {
|
switch (tabName) {
|
||||||
case "look":
|
case "look":
|
||||||
$activeTab = $look;
|
$activeTab = $look;
|
||||||
break;
|
break;
|
||||||
|
@ -1486,9 +1579,9 @@ window.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
|
||||||
// Show the requested tab on first page load.
|
// Show the requested tab on first page load.
|
||||||
showTab(window.location.hash.replace(/^#/, ''));
|
showTab(window.location.hash.replace(/^#/, ''));
|
||||||
window.requestAnimationFrame(() => {
|
// window.requestAnimationFrame(() => {
|
||||||
window.scrollTo(0, 0);
|
// window.scrollTo(0, 0);
|
||||||
});
|
// });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Look & Feel tab scripts.
|
// Look & Feel tab scripts.
|
||||||
|
@ -1563,7 +1656,7 @@ window.addEventListener("DOMContentLoaded", (event) => {
|
||||||
$pushDeniedHelp = document.querySelector("#push-denied-help");
|
$pushDeniedHelp = document.querySelector("#push-denied-help");
|
||||||
|
|
||||||
// Is the Notification API unavailable?
|
// Is the Notification API unavailable?
|
||||||
if (typeof(window.Notification) === "undefined") {
|
if (typeof(window.Notification) === "undefined" || typeof(PushNotificationSubscribe) === "undefined") {
|
||||||
$pushStatusDefault.innerHTML = `<i class="fa fa-xmark mr-1"></i> Notification API unavailable`;
|
$pushStatusDefault.innerHTML = `<i class="fa fa-xmark mr-1"></i> Notification API unavailable`;
|
||||||
$pushStatusDefault.style.display = "";
|
$pushStatusDefault.style.display = "";
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -187,11 +187,38 @@
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
{{if .FeedbackPager.Total}}
|
{{if .FeedbackPager.Total}}
|
||||||
<span>
|
<div class="block">
|
||||||
Found <strong>{{.FeedbackPager.Total}}</strong> report{{Pluralize64 .FeedbackPager.Total}} about this user (page {{.FeedbackPager.Page}} of {{.FeedbackPager.Pages}}).
|
Found <strong>{{.FeedbackPager.Total}}</strong> report{{Pluralize64 .FeedbackPager.Total}} about this user (page {{.FeedbackPager.Page}} of {{.FeedbackPager.Pages}}).
|
||||||
</span>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Simple filters -->
|
||||||
|
<form action="{{.Request.URL.Path}}" method="GET">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<label class="label">Show:</label>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
<select name="show">
|
||||||
|
<optgroup label="By user account">
|
||||||
|
<option value="">All reports from or about this user</option>
|
||||||
|
<option value="about"{{if eq .Show "about"}} selected{{end}}>Reports about this user or their photos</option>
|
||||||
|
<option value="from"{{if eq .Show "from"}} selected{{end}}>Reports from this user about others</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Fuzzy search">
|
||||||
|
<option value="fuzzy"{{if eq .Show "fuzzy"}} selected{{end}}>All reports that contain this user's name (@{{.User.Username}})</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<a href="{{.Request.URL.Path}}" class="button">Reset</a>
|
||||||
|
<button type="submit" class="button is-primary">Apply</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="my-4">
|
<div class="my-4">
|
||||||
{{SimplePager .FeedbackPager}}
|
{{SimplePager .FeedbackPager}}
|
||||||
</div>
|
</div>
|
||||||
|
@ -224,29 +251,29 @@
|
||||||
{{if ne .TableID 0}} - {{.TableID}}{{end}}
|
{{if ne .TableID 0}} - {{.TableID}}{{end}}
|
||||||
{{else if eq .TableName "users"}}
|
{{else if eq .TableName "users"}}
|
||||||
Users: {{.TableID}}
|
Users: {{.TableID}}
|
||||||
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true"
|
<a href="/admin/feedback?id={{.ID}}&visit=true"
|
||||||
class="fa fa-external-link ml-2"
|
class="fa fa-external-link ml-2"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
title="Visit the reported user's profile"></a>
|
title="Visit the reported user's profile"></a>
|
||||||
{{else if eq .TableName "photos"}}
|
{{else if eq .TableName "photos"}}
|
||||||
Photos: {{.TableID}}
|
Photos: {{.TableID}}
|
||||||
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true"
|
<a href="/admin/feedback?id={{.ID}}&visit=true"
|
||||||
class="fa fa-external-link mx-2"
|
class="fa fa-external-link mx-2"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
title="Visit the reported photo"></a>
|
title="Visit the reported photo"></a>
|
||||||
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true&profile=true"
|
<a href="/admin/feedback?id={{.ID}}&visit=true&profile=true"
|
||||||
class="fa fa-user"
|
class="fa fa-user"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
title="Visit the user profile who owns the reported photo"></a>
|
title="Visit the user profile who owns the reported photo"></a>
|
||||||
{{else if eq .TableName "messages"}}
|
{{else if eq .TableName "messages"}}
|
||||||
Messages: {{.TableID}}
|
Messages: {{.TableID}}
|
||||||
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true"
|
<a href="/admin/feedback?id={{.ID}}&visit=true"
|
||||||
class="fa fa-ghost ml-2"
|
class="fa fa-ghost ml-2"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
title="Impersonate the reporter and view this message thread"></a>
|
title="Impersonate the reporter and view this message thread"></a>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{.TableName}}: {{.TableID}}
|
{{.TableName}}: {{.TableID}}
|
||||||
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true" class="fa fa-external-link ml-2" target="_blank"></a>
|
<a href="/admin/feedback?id={{.ID}}&visit=true" class="fa fa-external-link ml-2" target="_blank"></a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -177,9 +177,11 @@
|
||||||
<option value="Your certification pic should depict you holding onto a sheet of paper with your username, site name, and current date written on it.">
|
<option value="Your certification pic should depict you holding onto a sheet of paper with your username, site name, and current date written on it.">
|
||||||
Didn't follow directions
|
Didn't follow directions
|
||||||
</option>
|
</option>
|
||||||
<option value="The sheet of paper must also include the website name: nonshy">Website name not visible</option>
|
<option value="The sheet of paper must also include the website name: nonshy.">Website name not visible</option>
|
||||||
<option value="Please take a clearer picture that shows your arm and hand holding onto the sheet of paper">Unclear picture (hand not visible enough)</option>
|
<option value="Please take a clearer picture that shows your arm and hand holding onto the sheet of paper.">Unclear picture (hand not visible enough)</option>
|
||||||
<option value="This photo has been digitally altered, please take a new certification picture and upload it as it comes off your camera">Photoshopped or digitally altered</option>
|
<option value="This photo has been digitally altered, please take a new certification picture and upload it as it comes off your camera.">Photoshopped or digitally altered</option>
|
||||||
|
<option value="Your certification photo should feature a hand written message on paper so we know you're a real person. It looks like you added text digitally to this picture, which isn't acceptable because anybody could have downloaded a picture like this from online and added text to it in that way.\n\nPlease take a picture with your certification message hand written on paper and upload it how it came off the camera.">Text appears added digitally</option>
|
||||||
|
<option value="The sheet of paper that you are holding in this picture is not readable. Please take a clearer picture that shows you holding onto a sheet of paper which has the website's name (nonshy), your username, and today's date written and be sure that it is readable.">Cert message is not legible</option>
|
||||||
<option value="You had a previous account on nonshy which was suspended and you are not welcome with a new account.">User was previously banned from nonshy</option>
|
<option value="You had a previous account on nonshy which was suspended and you are not welcome with a new account.">User was previously banned from nonshy</option>
|
||||||
<option value="This is not an acceptable certification photo.">Not acceptable</option>
|
<option value="This is not an acceptable certification photo.">Not acceptable</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
|
@ -211,7 +213,8 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if not (eq .Status "approved")}}
|
{{if not (eq .Status "approved")}}
|
||||||
<button type="submit" name="verdict" value="approve" class="card-footer-item button is-success">
|
<button type="submit" name="verdict" value="approve" class="card-footer-item button is-success"
|
||||||
|
{{if eq $Root.View "rejected"}}onclick="return confirm('Are you SURE you want to mark this photo as Approved?\n\nKeep in mind you are currently viewing the Rejected list of photos!')"{{end}}>
|
||||||
<span class="icon"><i class="fa fa-check"></i></span>
|
<span class="icon"><i class="fa fa-check"></i></span>
|
||||||
<span>Approve</span>
|
<span>Approve</span>
|
||||||
</button>
|
</button>
|
||||||
|
@ -245,7 +248,7 @@
|
||||||
textarea.addEventListener("change", setApproveState);
|
textarea.addEventListener("change", setApproveState);
|
||||||
textarea.addEventListener("keyup", setApproveState);
|
textarea.addEventListener("keyup", setApproveState);
|
||||||
elem.addEventListener("change", (e) => {
|
elem.addEventListener("change", (e) => {
|
||||||
textarea.value = elem.value;
|
textarea.value = elem.value.replaceAll("\\n", "\n");
|
||||||
setApproveState();
|
setApproveState();
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
|
@ -23,13 +23,13 @@
|
||||||
<div class="tabs is-toggle">
|
<div class="tabs is-toggle">
|
||||||
<ul>
|
<ul>
|
||||||
<li{{if eq .Intent ""}} class="is-active"{{end}}>
|
<li{{if eq .Intent ""}} class="is-active"{{end}}>
|
||||||
<a href="{{.Request.URL.Path}}?acknowledged={{.Acknowledged}}">All</a>
|
<a href="{{.Request.URL.Path}}?{{QueryPlus "intent" ""}}">All</a>
|
||||||
</li>
|
</li>
|
||||||
<li{{if eq .Intent "contact"}} class="is-active"{{end}}>
|
<li{{if eq .Intent "contact"}} class="is-active"{{end}}>
|
||||||
<a href="{{.Request.URL.Path}}?acknowledged={{.Acknowledged}}&intent=contact">Contact</a>
|
<a href="{{.Request.URL.Path}}?{{QueryPlus "intent" "contact"}}">Contact</a>
|
||||||
</li>
|
</li>
|
||||||
<li{{if eq .Intent "report"}} class="is-active"{{end}}>
|
<li{{if eq .Intent "report"}} class="is-active"{{end}}>
|
||||||
<a href="{{.Request.URL.Path}}?acknowledged={{.Acknowledged}}&intent=report">Reports</a>
|
<a href="{{.Request.URL.Path}}?{{QueryPlus "intent" "report"}}">Reports</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -38,16 +38,93 @@
|
||||||
<div class="tabs is-toggle">
|
<div class="tabs is-toggle">
|
||||||
<ul>
|
<ul>
|
||||||
<li{{if not .Acknowledged}} class="is-active"{{end}}>
|
<li{{if not .Acknowledged}} class="is-active"{{end}}>
|
||||||
<a href="{{.Request.URL.Path}}?intent={{.Intent}}">Unread</a>
|
<a href="{{.Request.URL.Path}}?{{QueryPlus "acknowledged" "false"}}">Unread</a>
|
||||||
</li>
|
</li>
|
||||||
<li{{if .Acknowledged}} class="is-active"{{end}}>
|
<li{{if .Acknowledged}} class="is-active"{{end}}>
|
||||||
<a href="{{.Request.URL.Path}}?acknowledged=true&intent={{.Intent}}">Acknowledged</a>
|
<a href="{{.Request.URL.Path}}?{{QueryPlus "acknowledged" "true"}}">Acknowledged</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Search fields -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<form action="{{.Request.URL.Path}}" method="GET">
|
||||||
|
<input type="hidden" name="intent" value="{{.Intent}}">
|
||||||
|
<input type="hidden" name="acknowledged" value="{{.Acknowledged}}">
|
||||||
|
|
||||||
|
<div class="card nonshy-collapsible-mobile">
|
||||||
|
<header class="card-header has-background-link-light">
|
||||||
|
<p class="card-header-title has-text-dark">
|
||||||
|
Search Filters
|
||||||
|
</p>
|
||||||
|
<button class="card-header-icon" type="button">
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fa fa-angle-up"></i>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="columns">
|
||||||
|
|
||||||
|
<div class="column pr-1">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="q">Search terms:</label>
|
||||||
|
<input type="text" class="input"
|
||||||
|
name="q" id="q"
|
||||||
|
autocomplete="off"
|
||||||
|
value="{{.SearchTerm}}">
|
||||||
|
<p class="help">
|
||||||
|
Tip: you can <span class="has-text-success">"quote exact phrases"</span> and
|
||||||
|
<span class="has-text-success">-exclude</span> words (or
|
||||||
|
<span class="has-text-success">-"exclude phrases"</span>) from your search.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column px-1">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="subject">Subject:</label>
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
<select id="subject" name="subject">
|
||||||
|
<option value=""></option>
|
||||||
|
{{range .DistinctSubjects}}
|
||||||
|
<option value="{{.}}" {{if eq $Root.Subject .}}selected{{end}}>{{.}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column px-1">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="sort">Sort by:</label>
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
<select id="sort" name="sort">
|
||||||
|
<option value="created_at desc"{{if eq .Sort "created_at desc"}} selected{{end}}>Newest</option>
|
||||||
|
<option value="created_at asc"{{if eq .Sort "created_at asc"}} selected{{end}}>Oldest</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-narrow pl-1 has-text-right">
|
||||||
|
<label class="label"> </label>
|
||||||
|
<a href="{{.Request.URL.Path}}" class="button">Reset</a>
|
||||||
|
<button type="submit" class="button is-success">
|
||||||
|
<span>Search</span>
|
||||||
|
<span class="icon"><i class="fa fa-search"></i></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
{{SimplePager .Pager}}
|
{{SimplePager .Pager}}
|
||||||
|
|
||||||
<div class="columns is-multiline">
|
<div class="columns is-multiline">
|
||||||
|
@ -151,6 +228,24 @@
|
||||||
{{else}}
|
{{else}}
|
||||||
{{ToMarkdown .Message}}
|
{{ToMarkdown .Message}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Photo attachment? -->
|
||||||
|
{{if eq .TableName "photos"}}
|
||||||
|
{{$Photo := $Root.PhotoMap.Get .TableID}}
|
||||||
|
{{if $Photo}}
|
||||||
|
<div class="is-clipped">
|
||||||
|
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true">
|
||||||
|
<img src="{{PhotoURL $Photo.Filename}}"
|
||||||
|
class="blurred-explicit">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 has-text-smaller">
|
||||||
|
<a href="/photo/edit?id={{.TableID}}">
|
||||||
|
<i class="fa fa-edit mr-1"></i> Edit this photo
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -12,6 +12,8 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{{$Root := .}}
|
||||||
|
|
||||||
<div class="block p-4">
|
<div class="block p-4">
|
||||||
<div class="columns is-centered">
|
<div class="columns is-centered">
|
||||||
<div class="column is-half">
|
<div class="column is-half">
|
||||||
|
@ -22,6 +24,9 @@
|
||||||
{{if eq .Intent "impersonate"}}
|
{{if eq .Intent "impersonate"}}
|
||||||
<i class="mr-2 fa fa-ghost"></i>
|
<i class="mr-2 fa fa-ghost"></i>
|
||||||
Impersonate User
|
Impersonate User
|
||||||
|
{{else if eq .Intent "chat.rules"}}
|
||||||
|
<i class="mr-2 fa fa-gavel"></i>
|
||||||
|
Chat Moderation Rules
|
||||||
{{else if eq .Intent "essays"}}
|
{{else if eq .Intent "essays"}}
|
||||||
<i class="mr-2 fa fa-pencil"></i>
|
<i class="mr-2 fa fa-pencil"></i>
|
||||||
Edit Profile Text
|
Edit Profile Text
|
||||||
|
@ -99,6 +104,23 @@
|
||||||
|
|
||||||
<h3>Block Lists</h3>
|
<h3>Block Lists</h3>
|
||||||
|
|
||||||
|
<!-- Surface if admin users are blocked -->
|
||||||
|
{{if .AdminBlockCount}}
|
||||||
|
<h5 class="has-text-danger">
|
||||||
|
<i class="fa fa-peace"></i>
|
||||||
|
Blocked Admins <span class="tag is-danger">{{.AdminBlockCount}}</span>
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This user blocks <strong>{{.AdminBlockCount}}</strong> out of {{.AdminBlockTotal}} admin{{Pluralize64 .AdminBlockTotal}} of this website.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If this number is unusually high, it can indicate this user may be proactively blocking all the admins in order to be
|
||||||
|
sneaky or evade moderation.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<h5 class="has-text-warning">Forward List <span class="tag is-warning">{{len .BlocklistInsights.Blocks}}</span></h5>
|
<h5 class="has-text-warning">Forward List <span class="tag is-warning">{{len .BlocklistInsights.Blocks}}</span></h5>
|
||||||
|
@ -118,6 +140,9 @@
|
||||||
{{range .BlocklistInsights.Blocks}}
|
{{range .BlocklistInsights.Blocks}}
|
||||||
<li>
|
<li>
|
||||||
<a href="/u/{{.Username}}">{{.Username}}</a>
|
<a href="/u/{{.Username}}">{{.Username}}</a>
|
||||||
|
{{if .IsAdmin}}
|
||||||
|
<sup class="has-text-warning fa fa-peace" title="Admin user"></sup>
|
||||||
|
{{end}}
|
||||||
<small class="has-text-grey" title="{{.Date}}">{{.Date.Format "2006-01-02"}}</small>
|
<small class="has-text-grey" title="{{.Date}}">{{.Date.Format "2006-01-02"}}</small>
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
@ -141,6 +166,9 @@
|
||||||
{{range .BlocklistInsights.BlockedBy}}
|
{{range .BlocklistInsights.BlockedBy}}
|
||||||
<li>
|
<li>
|
||||||
<a href="/u/{{.Username}}">{{.Username}}</a>
|
<a href="/u/{{.Username}}">{{.Username}}</a>
|
||||||
|
{{if .IsAdmin}}
|
||||||
|
<sup class="has-text-warning fa fa-peace" title="Admin user"></sup>
|
||||||
|
{{end}}
|
||||||
<small class="has-text-grey" title="{{.Date}}">{{.Date.Format "2006-01-02"}}</small>
|
<small class="has-text-grey" title="{{.Date}}">{{.Date.Format "2006-01-02"}}</small>
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
@ -148,6 +176,45 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{else if eq .Intent "chat.rules"}}
|
||||||
|
<div class="block content">
|
||||||
|
<p>
|
||||||
|
You may use this page to add or remove <strong>chat moderation rules</strong> for this user account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Moderation rules are useful to apply restrictions to certain problematic users who habitually break
|
||||||
|
the site rules. For example: somebody who insists on keeping their camera "blue" (non-explicit) while
|
||||||
|
always jerking off and resisting the admin request that their camera should be marked "red" can have that
|
||||||
|
choice taken away from them, and have their camera be forced red at all times when they are broadcasting.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Note:</strong> <a href="/faq#shy-faqs">"Shy Accounts"</a> automatically have the <strong>No webcam privileges</strong>
|
||||||
|
and <strong>No image sharing privileges</strong> rules applied when they log onto the chat room.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{range .ChatModerationRules}}
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="rules"
|
||||||
|
value="{{.Value}}"
|
||||||
|
{{if $Root.User.ProfileFieldIn "chat_moderation_rules" .Value}}checked{{end}}>
|
||||||
|
{{.Label}}
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
{{.Help}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="field has-text-centered">
|
||||||
|
<button type="submit" class="button is-success">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{{else if eq .Intent "essays"}}
|
{{else if eq .Intent "essays"}}
|
||||||
<div class="block content">
|
<div class="block content">
|
||||||
<p>
|
<p>
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
{{define "title"}}Untitled{{end}}
|
{{define "title"}}Untitled{{end}}
|
||||||
{{define "content"}}{{end}}
|
{{define "content"}}{{end}}
|
||||||
{{define "scripts"}}{{end}}
|
{{define "scripts"}}{{end}}
|
||||||
{{define "head-scripts"}}{{end}}
|
|
||||||
{{define "base"}}
|
{{define "base"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
@ -10,21 +9,25 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" type="text/css" href="/static/css/bulma.min.css?build={{.BuildHash}}">
|
<link rel="stylesheet" type="text/css" href="/static/css/bulma.min.css?build={{.BuildHash}}">
|
||||||
<!-- Bulma theme CSS -->
|
<!-- Bulma theme CSS -->
|
||||||
{{if eq .WebsiteTheme "light"}}
|
{{- if eq .WebsiteTheme "light" -}}
|
||||||
<link rel="stylesheet" type="text/css" href="/static/css/bulma-no-dark-mode.min.css?build={{.BuildHash}}">
|
<link rel="stylesheet" type="text/css" href="/static/css/bulma-no-dark-mode.min.css?build={{.BuildHash}}">
|
||||||
{{else if eq .WebsiteTheme "dark"}}
|
{{- else if eq .WebsiteTheme "dark" -}}
|
||||||
<link rel="stylesheet" type="text/css" href="/static/css/bulma-dark-theme.css?build={{.BuildHash}}">
|
<link rel="stylesheet" type="text/css" href="/static/css/bulma-dark-theme.css?build={{.BuildHash}}">
|
||||||
{{else}}
|
{{- else -}}
|
||||||
<link rel="stylesheet" type="text/css" href="/static/css/nonshy-prefers-dark.css?build={{.BuildHash}}">
|
<link rel="stylesheet" type="text/css" href="/static/css/nonshy-prefers-dark.css?build={{.BuildHash}}">
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
<!-- User theme hue -->
|
||||||
|
{{- if and .LoggedIn (.CurrentUser.GetProfileField "website-theme-hue") -}}
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/css/theme-{{.CurrentUser.GetProfileField "website-theme-hue"}}.css">
|
||||||
{{end}}
|
{{end}}
|
||||||
<link rel="stylesheet" href="/static/fontawesome-free-6.6.0-web/css/all.css">
|
<link rel="stylesheet" href="/static/fontawesome-free-6.6.0-web/css/all.css">
|
||||||
<link rel="stylesheet" href="/static/css/theme.css?build={{.BuildHash}}">
|
<link rel="stylesheet" href="/static/css/theme.css?build={{.BuildHash}}">
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<title>{{template "title" .}} - {{ .Title }}</title>
|
<title>{{template "title" .}} - {{ .Title }}</title>
|
||||||
{{template "head-scripts" .}}
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="has-navbar-fixed-top">
|
||||||
<nav class="navbar" role="navigation" aria-label="main navigation">
|
<nav class="navbar is-fixed-top" role="navigation" aria-label="main navigation">
|
||||||
<div class="navbar-brand">
|
<div class="navbar-brand">
|
||||||
<a class="navbar-item" href="/">
|
<a class="navbar-item" href="/">
|
||||||
{{ PrettyTitle }}
|
{{ PrettyTitle }}
|
||||||
|
@ -381,7 +384,10 @@
|
||||||
<script type="text/javascript" src="/static/js/vue-3.2.45.js"></script>
|
<script type="text/javascript" src="/static/js/vue-3.2.45.js"></script>
|
||||||
<script type="text/javascript" src="/static/js/htmx-1.9.12.min.js"></script>
|
<script type="text/javascript" src="/static/js/htmx-1.9.12.min.js"></script>
|
||||||
<script type="text/javascript" src="/static/js/slim-forms.js?build={{.BuildHash}}"></script>
|
<script type="text/javascript" src="/static/js/slim-forms.js?build={{.BuildHash}}"></script>
|
||||||
|
{{if not .SessionImpersonated -}}
|
||||||
|
{{- /* Disable web push script if impersonated, so an admin doesn't subscribe to user's notifications */ -}}
|
||||||
<script type="text/javascript" src="/static/js/web-push.js?build={{.BuildHash}}"></script>
|
<script type="text/javascript" src="/static/js/web-push.js?build={{.BuildHash}}"></script>
|
||||||
|
{{- end}}
|
||||||
{{template "scripts" .}}
|
{{template "scripts" .}}
|
||||||
|
|
||||||
<!-- Likes modal -->
|
<!-- Likes modal -->
|
||||||
|
|
|
@ -28,7 +28,7 @@
|
||||||
<div class="block p-4">
|
<div class="block p-4">
|
||||||
{{if .CurrentUser.IsBirthday}}
|
{{if .CurrentUser.IsBirthday}}
|
||||||
<div class="notification is-success is-light content">
|
<div class="notification is-success is-light content">
|
||||||
<h2>🎂 Happy birthday!</h2>
|
<span class="is-size-3">🎂 Happy birthday!</span>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
If you would like, you may enter the chat room with a special 🍰 birthday cake emoji next to
|
If you would like, you may enter the chat room with a special 🍰 birthday cake emoji next to
|
||||||
|
@ -48,11 +48,27 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .IsShyUser}}
|
{{if .IsShyUser}}
|
||||||
<div class="notification is-danger is-light">
|
<div class="notification is-warning is-light content">
|
||||||
<i class="fa fa-exclamation-triangle"></i> You have a <strong>Shy Account</strong> and you may not enter
|
<p>
|
||||||
the chat room at this time, where our {{PrettyTitle}} members may be sharing their cameras. You are
|
<i class="fa fa-exclamation-triangle"></i> You have a <strong>Shy Account</strong>, so you will experience
|
||||||
sharing no public photos with the community, so you get limited access to ours.
|
limited functionality on the chat room:
|
||||||
<a href="/faq#shy-faqs">Learn more about how to resolve this issue. <small class="fa fa-external-link"></small></a>
|
</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>You may not broadcast or watch any webcam on chat.</li>
|
||||||
|
<li>You may not share or see pictures shared by other members on chat.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This is because, as a Shy Account, you are not sharing any public photos with the community on your profile
|
||||||
|
page, so to most other members of {{PrettyTitle}} you appear to be a "blank, faceless profile" and people on
|
||||||
|
the chat room generally feel uncomfortable having their webcams be watched by such a Shy Account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="/faq#shy-faqs">Click here to learn more</a> about your Shy Account, including steps on how to
|
||||||
|
resolve this.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
@ -90,6 +106,11 @@
|
||||||
<i class="fa fa-video has-text-danger"></i> <span id="cameraRed" class="has-text-danger">0</span>).
|
<i class="fa fa-video has-text-danger"></i> <span id="cameraRed" class="has-text-danger">0</span>).
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Link to the search page if people are online -->
|
||||||
|
<div class="mt-2">
|
||||||
|
<strong>New:</strong> you can now search for <a href="/members?on_chat=true">who's on chat</a> in the member directory!
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Chat Room Rules</h3>
|
<h3>Chat Room Rules</h3>
|
||||||
|
|
245
web/templates/demographics.html
Normal file
245
web/templates/demographics.html
Normal file
|
@ -0,0 +1,245 @@
|
||||||
|
{{define "title"}}A peek inside the {{.Title}} website{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="block">
|
||||||
|
<section class="hero is-light is-bold">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">A peek inside the {{PrettyTitle}} website</h1>
|
||||||
|
<h2 class="subtitle">Some statistics & demographics of our members & content</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block p-4">
|
||||||
|
<div class="content">
|
||||||
|
<p>
|
||||||
|
This page provides some insights into the distribution of content and member demographics
|
||||||
|
in the {{PrettyTitle}} community.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you are a prospective new member and are curious about whether this isn't actually "just another porn site,"
|
||||||
|
hopefully this page will help answer some of those questions for you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
We have found that {{PrettyTitle}} actually fills a much needed niche <em>in between</em> the two
|
||||||
|
opposite poles of the "strict naturist website" and the "hyper sexual porn sites" that exist elsewhere
|
||||||
|
online. Many of our members have come here from other major nudist websites, and we have so far maintained a
|
||||||
|
nice balance in content: <strong>most</strong> of what people share here are "normal nudes" that
|
||||||
|
don't contain any sexual undertones at all!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
On our <a href="/features#webcam-chat-room">webcam chat room</a>, though we permit people to be sexual
|
||||||
|
on camera when they want to be, we typically maintain a balance where <em>most</em> of the webcams are
|
||||||
|
"blue" (or non-sexual in nature). Members are asked to mark their webcams as 'explicit' (red) when they are
|
||||||
|
being horny, so you may have informed consent as to whether you want to open their red cam and watch.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The insights on this page should hopefully back up this story with some hard numbers so you may get a preview
|
||||||
|
of what you may expect to find on this website should you decide to join us here.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{{PrettyTitle}} is open to <strong>all</strong> nudists & exhibitionists and we'd love to have you join our community!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong><em>Last Updated:</em></strong> these website statistics were last collected
|
||||||
|
on {{.Demographic.LastUpdated.Format "Jan _2, 2006 @ 15:04:05 MST"}}
|
||||||
|
|
||||||
|
<!-- Admin: force refresh -->
|
||||||
|
{{if and .LoggedIn .CurrentUser.IsAdmin}}
|
||||||
|
<a href="{{.Request.URL.Path}}?refresh=true" class="has-text-danger ml-2">
|
||||||
|
<i class="fa fa-peace"></i> Refresh now
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Photo Gallery Statistics
|
||||||
|
-->
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<h2>
|
||||||
|
<i class="fa fa-image mr-2"></i>
|
||||||
|
Photo Gallery
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Our members have shared <strong>{{FormatNumberCommas .Demographic.Photo.Total}}</strong> photos on
|
||||||
|
"public" for the whole {{PrettyTitle}} community to see. The majority of these photos tend to be "normal nudes"
|
||||||
|
and are non-sexual in nature (for example, not featuring so much as an erection or sexually enticing pose).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-2">
|
||||||
|
<strong>Non-Explicit</strong>
|
||||||
|
<small class="has-text-grey">({{FormatNumberCommas .Demographic.Photo.NonExplicit}})</small>
|
||||||
|
</div>
|
||||||
|
<div class="column pt-4">
|
||||||
|
<progress class="progress is-link"
|
||||||
|
value="{{.Demographic.Photo.PercentNonExplicit}}"
|
||||||
|
max="100"
|
||||||
|
title="{{.Demographic.Photo.PercentNonExplicit}}%">
|
||||||
|
{{.Demographic.Photo.PercentNonExplicit}}%
|
||||||
|
</progress>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow nonshy-percent-alignment">
|
||||||
|
{{.Demographic.Photo.PercentNonExplicit}}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-2">
|
||||||
|
<strong>Explicit</strong>
|
||||||
|
<small class="has-text-grey">({{FormatNumberCommas .Demographic.Photo.Explicit}})</small>
|
||||||
|
</div>
|
||||||
|
<div class="column pt-4">
|
||||||
|
<progress class="progress is-danger"
|
||||||
|
value="{{.Demographic.Photo.PercentExplicit}}"
|
||||||
|
max="100"
|
||||||
|
title="{{.Demographic.Photo.PercentExplicit}}%">
|
||||||
|
{{.Demographic.Photo.PercentExplicit}}%
|
||||||
|
</progress>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow nonshy-percent-alignment">
|
||||||
|
{{.Demographic.Photo.PercentExplicit}}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>
|
||||||
|
Remember: this website is "nudist friendly" by default, and you must <strong>opt-in</strong> if you want to
|
||||||
|
see the 'explicit' content on this website.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
So far: <strong>{{FormatNumberCommas .Demographic.People.ExplicitOptIn}}</strong> of our members
|
||||||
|
({{.Demographic.People.PercentExplicit}}%) opt-in to see explicit content, and
|
||||||
|
<strong>{{FormatNumberCommas .Demographic.People.ExplicitPhoto}}</strong>
|
||||||
|
({{.Demographic.People.PercentExplicitPhoto}}%) of them have actually shared at least one 'explicit'
|
||||||
|
photo on their public gallery.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
People statistics
|
||||||
|
-->
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<h2>
|
||||||
|
<i class="fa fa-people-group mr-2"></i>
|
||||||
|
People
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
We currently have <strong>{{FormatNumberCommas .Demographic.People.Total}}</strong> members who have
|
||||||
|
verified their <a href="/faq#certification-faqs">certification photo</a> with the site admin.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Below are some high-level demographics of who you may expect to find on this website. As a note on
|
||||||
|
genders: the majority of our members tend to be men, but we do have a handful of women here too.
|
||||||
|
{{PrettyTitle}} grows <em>exclusively</em> by word of mouth: if you know any women who might like
|
||||||
|
the vibe of this place, please do recommend that they check it out!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<h4 class="is-size-5 mb-4">By Age Range</h4>
|
||||||
|
|
||||||
|
{{range .Demographic.People.IterAgeRanges}}
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-3">
|
||||||
|
<strong>{{or .Label "(No answer)"}}</strong>
|
||||||
|
<small class="has-text-grey">({{FormatNumberCommas .Count}})</small>
|
||||||
|
</div>
|
||||||
|
<div class="column pt-4">
|
||||||
|
<progress class="progress is-success"
|
||||||
|
value="{{.Percent}}"
|
||||||
|
max="100"
|
||||||
|
title="{{.Percent}}%">
|
||||||
|
{{.Percent}}%
|
||||||
|
</progress>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow nonshy-percent-alignment">
|
||||||
|
{{.Percent}}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
|
<h4 class="is-size-5 mb-4">By Gender</h4>
|
||||||
|
|
||||||
|
{{range .Demographic.People.IterGenders}}
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-3">
|
||||||
|
<strong>{{or .Label "No answer"}}</strong>
|
||||||
|
<small class="has-text-grey">({{FormatNumberCommas .Count}})</small>
|
||||||
|
</div>
|
||||||
|
<div class="column pt-4">
|
||||||
|
<progress class="progress is-link"
|
||||||
|
value="{{.Percent}}"
|
||||||
|
max="100"
|
||||||
|
title="{{.Percent}}%">
|
||||||
|
{{.Percent}}%
|
||||||
|
</progress>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow nonshy-percent-alignment">
|
||||||
|
{{.Percent}}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-half">
|
||||||
|
<h4 class="is-size-5 mb-4">By Orientation</h4>
|
||||||
|
|
||||||
|
{{range .Demographic.People.IterOrientations}}
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-3">
|
||||||
|
<strong>{{or .Label "No answer"}}</strong>
|
||||||
|
<small class="has-text-grey">({{FormatNumberCommas .Count}})</small>
|
||||||
|
</div>
|
||||||
|
<div class="column pt-4">
|
||||||
|
<progress class="progress is-danger"
|
||||||
|
value="{{.Percent}}"
|
||||||
|
max="100"
|
||||||
|
title="{{.Percent}}%">
|
||||||
|
{{.Percent}}%
|
||||||
|
</progress>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow nonshy-percent-alignment">
|
||||||
|
{{.Percent}}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile CSS tweaks for this page -->
|
||||||
|
<style type="text/css">
|
||||||
|
.nonshy-percent-alignment {
|
||||||
|
min-width: 4.5rem;
|
||||||
|
}
|
||||||
|
@media screen and (min-width: 769px) {
|
||||||
|
.nonshy-percent-alignment {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{end}}
|
|
@ -3,7 +3,7 @@
|
||||||
<body bakground="#ffffff" color="#000000" link="#0000FF" vlink="#990099" alink="#FF0000">
|
<body bakground="#ffffff" color="#000000" link="#0000FF" vlink="#990099" alink="#FF0000">
|
||||||
<basefont face="Arial,Helvetica,sans-serif" size="3" color="#000000"></basefont>
|
<basefont face="Arial,Helvetica,sans-serif" size="3" color="#000000"></basefont>
|
||||||
|
|
||||||
<h1>Your certification photo has been rejected</h1>
|
<h1>Your certification photo has been denied</h1>
|
||||||
|
|
||||||
<p>Dear {{.Data.Username}},</p>
|
<p>Dear {{.Data.Username}},</p>
|
||||||
|
|
||||||
|
|
|
@ -4,10 +4,33 @@
|
||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="title">Certification Required</h1>
|
<h1 class="title">Certification Required</h1>
|
||||||
|
{{if and .CurrentUser.Certified (not .CurrentUser.ProfilePhoto.ID)}}
|
||||||
|
<h2 class="subtitle">You are just missing a default profile photo!</h2>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Just missing a cert photo? -->
|
||||||
|
{{if and .CurrentUser.Certified (not .CurrentUser.ProfilePhoto.ID)}}
|
||||||
|
<div class="notification is-success is-light content">
|
||||||
|
<p>
|
||||||
|
<strong>Notice:</strong> your Certification Photo is OK, you are just missing a profile picture!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
To maintain your <strong>certified</strong> status on this website, you are required to keep a
|
||||||
|
<strong>default profile picture</strong> set on your account at all times.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Please <a href="/u/{{.CurrentUser.Username}}/photos">visit your Photo Gallery</a> for instructions
|
||||||
|
to set one of your existing photos as your default, or
|
||||||
|
<a href="/photo/upload?intent=profile_pic">upload a new profile picture.</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="block content p-4 mb-0">
|
<div class="block content p-4 mb-0">
|
||||||
<h1>Certification Required</h1>
|
<h1>Certification Required</h1>
|
||||||
<p>
|
<p>
|
||||||
|
|
|
@ -701,6 +701,12 @@
|
||||||
content from other users -- by default this site is "normal nudes" friendly!
|
content from other users -- by default this site is "normal nudes" friendly!
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Please see the <a href="/tos#explicit-photos">Explicit Photos & Sexual Content</a>
|
||||||
|
policy on our <a href="/tos">Terms of Service</a> for some examples when a photo should
|
||||||
|
be marked as 'explicit.'
|
||||||
|
</p>
|
||||||
|
|
||||||
<h3 id="photoshop">Are digitally altered or 'photoshopped' pictures okay?</h3>
|
<h3 id="photoshop">Are digitally altered or 'photoshopped' pictures okay?</h3>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
@ -1131,8 +1137,8 @@
|
||||||
<p>
|
<p>
|
||||||
The chat room is available to all <strong>certified</strong> members who have public photos
|
The chat room is available to all <strong>certified</strong> members who have public photos
|
||||||
on their profile page. <a href="#shy-faqs">Shy Accounts</a> who have private profiles or keep
|
on their profile page. <a href="#shy-faqs">Shy Accounts</a> who have private profiles or keep
|
||||||
all their pictures hidden are not permitted in the chat room at this time - but they may get
|
all their pictures hidden MAY join the chat room, but have certain restrictions applied (such
|
||||||
their own separate room later where they can bother other similarly shy members there.
|
as an inability to broadcast or watch any webcam, or share or see any picture shared on chat).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
@ -1400,9 +1406,9 @@
|
||||||
your Friend or have shared their private pictures with you.
|
your Friend or have shared their private pictures with you.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
You can not join the <i class="fa fa-message"></i> <strong>Chat Room</strong>. You guys
|
You <strong>can</strong> join the <i class="fa fa-message"></i> <strong>Chat Room</strong>, however
|
||||||
may soon get your own chat room, though. Many of us {{PrettyTitle}} nudists would not
|
some features will be restricted to Shy Accounts: you will not be able to broadcast OR watch any webcam
|
||||||
enjoy our webcams being watched by blank profiles.
|
on chat, nor can you share OR view any photo shared by others on the chat room.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
@ -1413,6 +1419,13 @@
|
||||||
kept with the other blank profiles until you choose to participate.
|
kept with the other blank profiles until you choose to participate.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
On the chat room, many {{PrettyTitle}} members may be sharing their webcams and it is widely
|
||||||
|
regarded as awkward to have a "blank, faceless profile" silently lurking on your camera. So, a
|
||||||
|
Shy Account is allowed on the chat room but can not share or watch webcams, or share or view photos
|
||||||
|
posted by other members on the chat room.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h3 id="shy-cando">What <em>can</em> Shy Accounts do?</h3>
|
<h3 id="shy-cando">What <em>can</em> Shy Accounts do?</h3>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
@ -1421,6 +1434,10 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
|
<li>
|
||||||
|
You can still join the <i class="fa fa-message"></i> <strong>Chat Room</strong> and have text-based
|
||||||
|
conversations with people (with just webcam and image sharing support restricted).
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
You can still participate on the <i class="fa fa-comments"></i> <strong>Forums</strong> and meet new friends
|
You can still participate on the <i class="fa fa-comments"></i> <strong>Forums</strong> and meet new friends
|
||||||
that way - by contributing to discussions, ideally.
|
that way - by contributing to discussions, ideally.
|
||||||
|
|
|
@ -140,18 +140,16 @@
|
||||||
</p>
|
</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .CurrentUser.HasAdminScope "admin.forum.manage"}}
|
|
||||||
<label class="checkbox mt-3">
|
<label class="checkbox mt-3">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="permit_photos"
|
name="permit_photos"
|
||||||
value="true"
|
value="true"
|
||||||
{{if and .EditForum .EditForum.PermitPhotos}}checked{{end}}>
|
{{if and .EditForum .EditForum.PermitPhotos}}checked{{end}}>
|
||||||
Permit Photos <i class="fa fa-camera ml-1"></i> <i class="fa fa-peace has-text-danger ml-1"></i>
|
Permit Photos <i class="fa fa-camera ml-1"></i>
|
||||||
</label>
|
</label>
|
||||||
<p class="help">
|
<p class="help">
|
||||||
Check this box if the forum allows photos to be uploaded (not implemented)
|
Check this box if the forum allows photos to be uploaded.
|
||||||
</p>
|
</p>
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if .CurrentUser.HasAdminScope "admin.forum.manage"}}
|
{{if .CurrentUser.HasAdminScope "admin.forum.manage"}}
|
||||||
<label class="checkbox mt-3">
|
<label class="checkbox mt-3">
|
||||||
|
|
|
@ -203,7 +203,13 @@
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- Container of img tags for the selected photo preview. -->
|
<!-- Container of img tags for the selected photo preview. -->
|
||||||
|
{{if and .CommentPhoto (HasSuffix .CommentPhoto.Filename ".mp4")}}
|
||||||
|
<video autoplay loop controls controlsList="nodownload" playsinline>
|
||||||
|
<source src="{{PhotoURL .CommentPhoto.Filename}}" type="video/mp4">
|
||||||
|
</video>
|
||||||
|
{{else}}
|
||||||
<img id="previewImage"{{if .CommentPhoto}} src="{{PhotoURL .CommentPhoto.Filename}}"{{end}}>
|
<img id="previewImage"{{if .CommentPhoto}} src="{{PhotoURL .CommentPhoto.Filename}}"{{end}}>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|
|
@ -140,16 +140,29 @@
|
||||||
</div>
|
</div>
|
||||||
[unavailable]
|
[unavailable]
|
||||||
{{else}}
|
{{else}}
|
||||||
|
<!-- User has no display name distinct from their username? -->
|
||||||
|
{{ $NoDisplayName := eq $c.User.NameOrUsername $c.User.Username }}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<a href="/u/{{$c.User.Username}}">
|
<a href="/u/{{$c.User.Username}}">
|
||||||
{{template "avatar-96x96" $c.User}}
|
{{template "avatar-96x96" $c.User}}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<a href="/u/{{$c.User.Username}}">{{$c.User.NameOrUsername}}</a>
|
<a href="/u/{{$c.User.Username}}">
|
||||||
|
{{- if $NoDisplayName}}<small class="is-size-7">@</small>{{end -}}
|
||||||
|
{{$c.User.NameOrUsername}}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Username if the display name wasn't identical -->
|
||||||
|
{{if not $NoDisplayName}}
|
||||||
|
<div class="is-size-7">
|
||||||
|
@{{$c.User.Username}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if $c.User.IsAdmin}}
|
{{if $c.User.IsAdmin}}
|
||||||
<div class="is-size-7 mt-1">
|
<div class="is-size-7 mt-2">
|
||||||
<span class="tag is-danger is-light">
|
<span class="tag is-danger is-light">
|
||||||
<span class="icon"><i class="fa fa-peace"></i></span>
|
<span class="icon"><i class="fa fa-peace"></i></span>
|
||||||
<span>Admin</span>
|
<span>Admin</span>
|
||||||
|
@ -306,14 +319,14 @@
|
||||||
{{if not $Root.Thread.NoReply}}
|
{{if not $Root.Thread.NoReply}}
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}"e={{.ID}}"
|
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}"e={{.ID}}"
|
||||||
class="has-text-dark nonshy-quote-button" data-quote-body="{{.Message}}" data-reply-to="{{.User.Username}}">
|
class="has-text-dark nonshy-quote-button" data-quote-body="{{.Message}}" data-reply-to="{{.User.Username}}" data-comment-id="{{.ID}}">
|
||||||
<span class="icon"><i class="fa fa-quote-right"></i></span>
|
<span class="icon"><i class="fa fa-quote-right"></i></span>
|
||||||
<span>Quote</span>
|
<span>Quote</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}"
|
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}"
|
||||||
class="has-text-dark nonshy-reply-button" data-reply-to="{{.User.Username}}">
|
class="has-text-dark nonshy-reply-button" data-reply-to="{{.User.Username}}" data-comment-id="{{.ID}}">
|
||||||
<span class="icon"><i class="fa fa-reply"></i></span>
|
<span class="icon"><i class="fa fa-reply"></i></span>
|
||||||
<span>Reply</span>
|
<span>Reply</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -69,18 +69,7 @@
|
||||||
the main website and the chat room.
|
the main website and the chat room.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-mobile">
|
|
||||||
<div class="column">
|
|
||||||
<button type="submit" class="button is-success">Send Reply</button>
|
<button type="submit" class="button is-success">Send Reply</button>
|
||||||
</div>
|
|
||||||
<div class="column is-narrow">
|
|
||||||
<a href="/contact?intent=report&subject=report.message&id={{.MessageID}}"
|
|
||||||
class="button has-text-danger ml-4">
|
|
||||||
<span class="icon"><i class="fa fa-flag"></i></span>
|
|
||||||
<span>Report</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
@ -151,15 +140,22 @@
|
||||||
|
|
||||||
<!-- Our message? We can delete it. -->
|
<!-- Our message? We can delete it. -->
|
||||||
{{if eq $Root.CurrentUser.ID $SourceUser.ID}}
|
{{if eq $Root.CurrentUser.ID $SourceUser.ID}}
|
||||||
<form action="/messages/delete" method="POST" class="is-inline" onsubmit="return confirm('Delete this message?')">
|
<form action="/messages/delete" method="POST" class="is-inline"
|
||||||
|
onsubmit="return confirm('Do you want to DELETE this message?')">
|
||||||
{{InputCSRF}}
|
{{InputCSRF}}
|
||||||
<input type="hidden" name="id" value="{{.ID}}">
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
<input type="hidden" name="next" value="{{$Root.Request.URL.Path}}">
|
<input type="hidden" name="next" value="{{$Root.Request.URL.Path}}">
|
||||||
<button class="button has-text-danger is-outline is-small p-1 ml-4">
|
<button class="button is-outline has-text-grey is-small ml-4">
|
||||||
<i class="fa fa-trash mr-2"></i>
|
<i class="fa fa-trash mr-2"></i>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<a href="/contact?intent=report&subject=report.message&id={{.ID}}"
|
||||||
|
class="button is-outline is-small has-text-danger ml-4">
|
||||||
|
<span class="icon"><i class="fa fa-flag"></i></span>
|
||||||
|
<span>Report</span>
|
||||||
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -41,29 +41,34 @@
|
||||||
content is strictly opt-in and the default is to hide any explicit photos or forums from your view.
|
content is strictly opt-in and the default is to hide any explicit photos or forums from your view.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Show a peek at website demographics -->
|
||||||
|
{{if .Demographic.Computed}}
|
||||||
<h4><em>A peek inside the site:</em></h4>
|
<h4><em>A peek inside the site:</em></h4>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{{PrettyTitle}} has been found to fill a much needed niche in between the "strict naturist websites"
|
{{PrettyTitle}} has been found to fill a much needed niche in between the "strict naturist websites"
|
||||||
and the "hyper sexual porn sites" out there. As of <strong>July 31, 2024</strong> here is a brief peek inside the
|
and the "hyper sexual porn sites" out there. As of <strong>{{.Demographic.LastUpdated.Format "January _2, 2006"}}</strong> here is a brief
|
||||||
website to see what the balance of content is like in our community.
|
<a href="/insights">peek inside the website</a> to see what the balance of content is like in our community.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
Photo Gallery: only 24% of our photos are 'explicit' or sexual in nature (6,750 out of 27,331).
|
Photo Gallery: only {{.Demographic.Photo.PercentExplicit}}% of our photos are 'explicit' or sexual in nature ({{FormatNumberCommas .Demographic.Photo.Explicit}} out of {{FormatNumberCommas .Demographic.Photo.Total}}).
|
||||||
It is strictly opt-in if you want to see that stuff - it's hidden by default!
|
It is strictly opt-in if you want to see that stuff - it's hidden by default!
|
||||||
</li>
|
</li>
|
||||||
<li>Nudists vs. Exhibitionists: 3,209 (71%, out of 4,462) of members have opted-in to see explicit content on the site.
|
<li>Nudists vs. Exhibitionists: {{FormatNumberCommas .Demographic.People.ExplicitOptIn}} ({{.Demographic.People.PercentExplicit}}%, out of {{FormatNumberCommas .Demographic.People.Total}}) of members have opted-in to see explicit content on the site.
|
||||||
Only 45% of members (2,022) have shared at least one 'explicit' photo on their gallery.</li>
|
Only {{.Demographic.People.PercentExplicitPhoto}}% of members ({{FormatNumberCommas .Demographic.People.ExplicitPhoto}}) have shared at least one 'explicit' photo on their gallery.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
<strong>
|
||||||
|
<i class="fa fa-circle-arrow-right mr-1"></i> See more:
|
||||||
|
</strong>
|
||||||
<small>
|
<small>
|
||||||
(Coming soon: a 'live statistics' page which will give up-to-date information and pretty graphs & charts;
|
<a href="/insights">Click here to see detailed insights</a> about the people and content in our community -- updated regularly!
|
||||||
in the mean time these were manually gathered).
|
|
||||||
</small>
|
</small>
|
||||||
</p>
|
</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -24,97 +24,84 @@ section.hero {
|
||||||
{{$cardTitleFG := or (.GetProfileField "card-title-fg") "#f7f7f7"}}
|
{{$cardTitleFG := or (.GetProfileField "card-title-fg") "#f7f7f7"}}
|
||||||
{{$cardLinkFG := or (.GetProfileField "card-link-color") "#0099ff"}}
|
{{$cardLinkFG := or (.GetProfileField "card-link-color") "#0099ff"}}
|
||||||
{{$cardLightness := .GetProfileField "card-lightness"}}
|
{{$cardLightness := .GetProfileField "card-lightness"}}
|
||||||
|
{{$heroA := or (.GetProfileField "hero-color-start") "#0f81cc"}}
|
||||||
|
{{$heroB := or (.GetProfileField "hero-color-end") "#7683cc"}}
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
{{template "profile-theme-hero-style" .}}
|
/* Hero banner */
|
||||||
header.card-header {
|
.user-theme-hero {
|
||||||
background-color: {{$cardTitleBG}} !important;
|
background-image: linear-gradient(141deg, {{$heroA}}, {{$heroB}}) !important;
|
||||||
|
|
||||||
|
.title, .subtitle {
|
||||||
|
color: {{if eq (.GetProfileField "hero-text-dark") "true"}}#4a4a4a{{else}}#f5f5f5{{end}} !important;
|
||||||
}
|
}
|
||||||
p.card-header-title {
|
}
|
||||||
|
|
||||||
|
/* Card Title colors */
|
||||||
|
.user-theme-card-title {
|
||||||
|
background-color: {{$cardTitleBG}} !important;
|
||||||
|
|
||||||
|
* {
|
||||||
color: {{$cardTitleFG}} !important;
|
color: {{$cardTitleFG}} !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button.is-outlined {
|
||||||
|
border-color: {{$cardTitleFG}} !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Body colors */
|
||||||
|
.user-theme-card-body {
|
||||||
|
a {
|
||||||
|
color: {{$cardLinkFG}} !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forced light theme overrides */
|
||||||
{{if eq $cardLightness "light"}}
|
{{if eq $cardLightness "light"}}
|
||||||
div.box, .container div.card-content, table.table, table.table strong, td {
|
.user-theme-card-body {
|
||||||
background-color: #fff !important;
|
background-color: #fff !important;
|
||||||
color: #4a4a4a !important;
|
color: #4a4a4a !important;
|
||||||
}
|
|
||||||
aside.menu ul.menu-list li a {
|
* {
|
||||||
background-color: #ccc !important;
|
|
||||||
color: #4a4a4a !important;
|
color: #4a4a4a !important;
|
||||||
}
|
}
|
||||||
blockquote, pre, code {
|
|
||||||
|
a > * {
|
||||||
|
color: {{$cardLinkFG}} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote, pre, code, .tag {
|
||||||
background-color: #ccc !important;
|
background-color: #ccc !important;
|
||||||
color: #4a4a4a;
|
color: #4a4a4a m !important;
|
||||||
}
|
|
||||||
div.tag {
|
|
||||||
background-color: #ccc;
|
|
||||||
color: #4a4a4a;
|
|
||||||
}
|
|
||||||
strong {
|
|
||||||
color: #4a4a4a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* More text color overrides (h1's etc. look light on prefers-dark color schemes) */
|
.table {
|
||||||
.container div.card-content .content * {
|
background-color: inherit;
|
||||||
color: #4a4a4a;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slightly less light on dark theme devices */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
div.box, .container div.card-content, table.table, table.table strong {
|
|
||||||
background-color: #e4e4e4;
|
|
||||||
color: #4a4a4a;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
{{else if eq $cardLightness "dark"}}
|
{{else if eq $cardLightness "dark"}}
|
||||||
div.box, .container div.card-content, table.table, table.table strong, td {
|
.user-theme-card-body {
|
||||||
background-color: #4a4a4a !important;
|
background-color: #4a4a4a !important;
|
||||||
color: #f5f5f5 !important;
|
color: #f5f5f5 !important;
|
||||||
}
|
|
||||||
aside.menu ul.menu-list li a {
|
* {
|
||||||
background-color: #1a1a1a !important;
|
|
||||||
color: #f5f5f5 !important;
|
color: #f5f5f5 !important;
|
||||||
}
|
}
|
||||||
blockquote, pre, code {
|
|
||||||
|
a > * {
|
||||||
|
color: {{$cardLinkFG}} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote, pre, code, .tag {
|
||||||
background-color: #1a1a1a !important;
|
background-color: #1a1a1a !important;
|
||||||
color: #f5f5f5;
|
color: #f5f5f5 m !important;
|
||||||
}
|
|
||||||
div.tag {
|
|
||||||
background-color: #333;
|
|
||||||
color: #f5f5f5;
|
|
||||||
}
|
|
||||||
strong {
|
|
||||||
color: #f5f5f5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* More text color overrides (h1's etc. look dark on prefers-light color schemes) */
|
.table {
|
||||||
.container div.card-content .content * {
|
background-color: inherit;
|
||||||
color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Even darker on dark theme devices */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
div.box, .container div.card-content, table.table, table.table strong {
|
|
||||||
background-color: #0a0a0a;
|
|
||||||
color: #b5b5b5;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
.container div.card-content a {
|
|
||||||
color: {{$cardLinkFG}} !important;
|
|
||||||
}
|
|
||||||
.card-content .menu-list li a {
|
|
||||||
color: inherit !important;
|
|
||||||
}
|
|
||||||
.container div.card-content a:hover {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Override link color for the Activity box, so users don't set black-on-black and wreck the links */
|
|
||||||
.card-content table a.has-text-info {
|
|
||||||
color: #3e8ed0 !important;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|
169
web/templates/photo/batch_edit.html
Normal file
169
web/templates/photo/batch_edit.html
Normal file
|
@ -0,0 +1,169 @@
|
||||||
|
{{define "title"}}Delete Photo{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="container">
|
||||||
|
<section class="hero is-link is-bold">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">
|
||||||
|
{{if eq .Intent "delete"}}
|
||||||
|
<i class="fa fa-trash mr-2"></i>
|
||||||
|
Delete {{len .Photos}} Photo{{Pluralize (len .Photos)}}
|
||||||
|
{{else if eq .Intent "visibility"}}
|
||||||
|
<i class="fa fa-eye mr-2"></i>
|
||||||
|
Edit Visibility
|
||||||
|
{{else}}
|
||||||
|
Batch Edit Photos
|
||||||
|
{{end}}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="block p-4">
|
||||||
|
<div class="level">
|
||||||
|
<div class="level-item">
|
||||||
|
<div class="card" style="max-width: 800px">
|
||||||
|
<header class="card-header {{if eq .Intent "delete"}}has-background-danger{{else}}has-background-link{{end}}">
|
||||||
|
<p class="card-header-title has-text-light">
|
||||||
|
{{if eq .Intent "delete"}}
|
||||||
|
<span class="icon"><i class="fa fa-trash"></i></span>
|
||||||
|
Delete {{len .Photos}} Photo{{Pluralize (len .Photos)}}
|
||||||
|
{{else if eq .Intent "visibility"}}
|
||||||
|
<span class="icon"><i class="fa fa-eye mr-2"></i></span>
|
||||||
|
Edit Visibility
|
||||||
|
{{else}}
|
||||||
|
Batch Edit Photos
|
||||||
|
{{end}}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div class="card-content">
|
||||||
|
<form method="POST" action="/photo/batch-edit">
|
||||||
|
{{InputCSRF}}
|
||||||
|
<input type="hidden" name="intent" value="{{.Intent}}">
|
||||||
|
<input type="hidden" name="confirm" value="true">
|
||||||
|
|
||||||
|
<!-- Bulk Visibility Settings -->
|
||||||
|
{{if eq .Intent "visibility"}}
|
||||||
|
<p>
|
||||||
|
You may use this page to set <strong>all ({{len .Photos}}) photo{{if ge (len .Photos) 2}}s'{{end}}</strong>
|
||||||
|
visibility setting.
|
||||||
|
</p>
|
||||||
|
<!-- TODO: copy/pasted block from the Upload page -->
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Photo Visibility</label>
|
||||||
|
<div>
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio"
|
||||||
|
name="visibility"
|
||||||
|
value="public"
|
||||||
|
{{if or (not .EditPhoto) (eq .EditPhoto.Visibility "public")}}checked{{end}}>
|
||||||
|
<strong class="has-text-link ml-1">
|
||||||
|
<span>Public <small>(members only)</small></span>
|
||||||
|
<span class="icon"><i class="fa fa-eye"></i></span>
|
||||||
|
</strong>
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
This photo will appear on your profile page and can be seen by any
|
||||||
|
logged-in user account. It may also appear on the site-wide Photo
|
||||||
|
Gallery if that option is enabled, below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio"
|
||||||
|
name="visibility"
|
||||||
|
value="friends"
|
||||||
|
{{if eq .EditPhoto.Visibility "friends"}}checked{{end}}>
|
||||||
|
<strong class="has-text-warning ml-1">
|
||||||
|
<span>Friends only</span>
|
||||||
|
<span class="icon"><i class="fa fa-user-group"></i></span>
|
||||||
|
</strong>
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
Only users you have accepted as a friend can see this photo on your
|
||||||
|
profile page and on the site-wide Photo Gallery if that option is
|
||||||
|
enabled, below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio"
|
||||||
|
name="visibility"
|
||||||
|
value="private"
|
||||||
|
{{if eq .EditPhoto.Visibility "private"}}checked{{end}}>
|
||||||
|
<strong class="has-text-private ml-1">
|
||||||
|
<span>Private</span>
|
||||||
|
<span class="icon"><i class="fa fa-lock"></i></span>
|
||||||
|
</strong>
|
||||||
|
</label>
|
||||||
|
<p class="help">
|
||||||
|
This photo is visible only to you and to users for whom you have
|
||||||
|
granted access
|
||||||
|
(<a href="/photo/private" target="_blank" class="has-text-private">manage grants <i class="fa fa-external-link"></i></a>).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="has-text-warning is-size-7 mt-4">
|
||||||
|
<i class="fa fa-info-circle mr-1"></i>
|
||||||
|
<strong class="has-text-warning">Reminder:</strong> There are risks inherent with sharing
|
||||||
|
pictures on the Internet, and {{PrettyTitle}} can't guarantee that another member of the site
|
||||||
|
won't download and possibly redistribute your photos. You may mark your picture as "Friends only"
|
||||||
|
or "Private" to limit who on the website will see it, but anybody who <em>can</em> see it could potentially
|
||||||
|
save it to their computer. <a href="/faq#downloading" target="_blank">Learn more <i class="fa fa-external-link"></i></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Show range of photos on all updates -->
|
||||||
|
<div class="columns is-mobile is-multiline">
|
||||||
|
{{range .Photos}}
|
||||||
|
<div class="column is-half">
|
||||||
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
|
<div class="image block">
|
||||||
|
<!-- GIF video? -->
|
||||||
|
{{if HasSuffix .Filename ".mp4"}}
|
||||||
|
<video autoplay loop controls controlsList="nodownload" playsinline>
|
||||||
|
<source src="{{PhotoURL .Filename}}" type="video/mp4">
|
||||||
|
</video>
|
||||||
|
{{else}}
|
||||||
|
<img src="{{PhotoURL .Filename}}">
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block">
|
||||||
|
Are you sure you want to
|
||||||
|
{{if eq .Intent "delete"}}
|
||||||
|
<strong class="has-text-danger">delete</strong>
|
||||||
|
{{else if eq .Intent "visibility"}}
|
||||||
|
<strong>update the visibility</strong> of
|
||||||
|
{{else}}
|
||||||
|
update
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if ge (len .Photos) 2 -}}
|
||||||
|
these <strong>{{len .Photos}} photos?</strong>
|
||||||
|
{{- else -}}
|
||||||
|
this photo?
|
||||||
|
{{- end}}
|
||||||
|
</div>
|
||||||
|
<div class="block has-text-center">
|
||||||
|
{{if eq .Intent "delete"}}
|
||||||
|
<button type="submit" class="button is-danger">Delete Photo{{Pluralize (len .Photos)}}</button>
|
||||||
|
{{else}}
|
||||||
|
<button type="submit" class="button is-primary">Update Photo{{Pluralize (len .Photos)}}</button>
|
||||||
|
{{end}}
|
||||||
|
<button type="button" class="button" onclick="history.back()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
|
@ -17,6 +17,12 @@
|
||||||
{{define "card-body"}}
|
{{define "card-body"}}
|
||||||
<div>
|
<div>
|
||||||
<small class="has-text-grey">Uploaded {{.CreatedAt.Format "Jan _2 2006 15:04:05"}}</small>
|
<small class="has-text-grey">Uploaded {{.CreatedAt.Format "Jan _2 2006 15:04:05"}}</small>
|
||||||
|
{{if .Views}}
|
||||||
|
<small class="has-text-grey is-size-7 ml-2">
|
||||||
|
<i class="fa fa-eye"></i>
|
||||||
|
{{.Views}}
|
||||||
|
</small>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
{{if .Pinned}}
|
{{if .Pinned}}
|
||||||
|
@ -67,6 +73,11 @@
|
||||||
|
|
||||||
<!-- Reusable card footer -->
|
<!-- Reusable card footer -->
|
||||||
{{define "card-footer"}}
|
{{define "card-footer"}}
|
||||||
|
<label class="card-footer-item checkbox">
|
||||||
|
<input type="checkbox" class="nonshy-edit-photo-id"
|
||||||
|
name="id"
|
||||||
|
value="{{.ID}}">
|
||||||
|
</label>
|
||||||
<a class="card-footer-item" href="/photo/edit?id={{.ID}}">
|
<a class="card-footer-item" href="/photo/edit?id={{.ID}}">
|
||||||
<span class="icon"><i class="fa fa-edit"></i></span>
|
<span class="icon"><i class="fa fa-edit"></i></span>
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
|
@ -414,6 +425,9 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
<option value="created_at desc"{{if eq .Sort "created_at desc"}} selected{{end}}>Most recent</option>
|
<option value="created_at desc"{{if eq .Sort "created_at desc"}} selected{{end}}>Most recent</option>
|
||||||
<option value="created_at asc"{{if eq .Sort "created_at asc"}} selected{{end}}>Oldest first</option>
|
<option value="created_at asc"{{if eq .Sort "created_at asc"}} selected{{end}}>Oldest first</option>
|
||||||
|
<option value="like_count desc"{{if eq .Sort "like_count desc"}} selected{{end}}>Most likes</option>
|
||||||
|
<option value="comment_count desc"{{if eq .Sort "comment_count desc"}} selected{{end}}>Most comments</option>
|
||||||
|
<option value="views desc"{{if eq .Sort "views desc"}} selected{{end}}>Most views</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -457,19 +471,22 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{{else if not .IsSiteGallery}}
|
{{else if not .IsSiteGallery}}
|
||||||
|
<!-- Private photo unlock/status prompt for this other user. -->
|
||||||
|
{{if .IsMyPrivateUnlockedFor}}
|
||||||
|
<div class="block">
|
||||||
|
<span class="icon"><i class="fa fa-unlock has-text-private"></i></span>
|
||||||
|
<span>You had granted <strong>{{.User.Username}}</strong> access to see <strong>your</strong> private photos.</span>
|
||||||
|
<a href="/photo/private">Manage that here.</a>
|
||||||
|
</div>
|
||||||
|
{{else if .ShowPrivateUnlockPrompt}}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
{{if not .IsMyPrivateUnlockedFor}}
|
|
||||||
<a href="/photo/private/share?to={{.User.Username}}" class="has-text-private">
|
<a href="/photo/private/share?to={{.User.Username}}" class="has-text-private">
|
||||||
<span class="icon"><i class="fa fa-unlock"></i></span>
|
<span class="icon"><i class="fa fa-unlock"></i></span>
|
||||||
<span>Grant <strong>{{.User.Username}}</strong> access to see <strong>my</strong> private photos</span>
|
<span>Grant <strong>{{.User.Username}}</strong> access to see <strong>my</strong> private photos</span>
|
||||||
</a>
|
</a>
|
||||||
{{else}}
|
|
||||||
<span class="icon"><i class="fa fa-unlock has-text-private"></i></span>
|
|
||||||
<span>You had granted <strong>{{.User.Username}}</strong> access to see <strong>your</strong> private photos.</span>
|
|
||||||
<a href="/photo/private">Manage that here.</a>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .AreWeGrantedPrivate}}
|
{{if .AreWeGrantedPrivate}}
|
||||||
<div class="block mt-0">
|
<div class="block mt-0">
|
||||||
|
@ -481,6 +498,9 @@
|
||||||
|
|
||||||
{{SimplePager .Pager}}
|
{{SimplePager .Pager}}
|
||||||
|
|
||||||
|
<!-- Form to wrap the gallery, e.g. for batch edits on user views. -->
|
||||||
|
<form action="/photo/batch-edit">
|
||||||
|
|
||||||
<!-- "Full" view style? (blog style) -->
|
<!-- "Full" view style? (blog style) -->
|
||||||
{{if eq .ViewStyle "full"}}
|
{{if eq .ViewStyle "full"}}
|
||||||
{{range .Photos}}
|
{{range .Photos}}
|
||||||
|
@ -533,6 +553,8 @@
|
||||||
<!-- GIF video? -->
|
<!-- GIF video? -->
|
||||||
{{if HasSuffix .Filename ".mp4"}}
|
{{if HasSuffix .Filename ".mp4"}}
|
||||||
<video loop controls controlsList="nodownload" playsinline
|
<video loop controls controlsList="nodownload" playsinline
|
||||||
|
class="js-modal-trigger"
|
||||||
|
data-url="{{PhotoURL .Filename}}" data-photo-id="{{.ID}}"
|
||||||
{{if .AltText}}title="{{.AltText}}"{{end}}
|
{{if .AltText}}title="{{.AltText}}"{{end}}
|
||||||
{{if BlurExplicit .}}class="blurred-explicit"
|
{{if BlurExplicit .}}class="blurred-explicit"
|
||||||
{{else if (not (eq ($Root.CurrentUser.GetProfileField "autoplay_gif") "false"))}}autoplay
|
{{else if (not (eq ($Root.CurrentUser.GetProfileField "autoplay_gif") "false"))}}autoplay
|
||||||
|
@ -540,8 +562,8 @@
|
||||||
<source src="{{PhotoURL .Filename}}" type="video/mp4">
|
<source src="{{PhotoURL .Filename}}" type="video/mp4">
|
||||||
</video>
|
</video>
|
||||||
{{else}}
|
{{else}}
|
||||||
<a href="/photo/view?id={{.ID}}" data-url="{{PhotoURL .Filename}}" target="_blank"
|
<a href="/photo/view?id={{.ID}}" data-url="{{PhotoURL .Filename}}" data-photo-id="{{.ID}}" target="_blank"
|
||||||
class="js-modal-trigger" data-target="detail-modal">
|
class="js-modal-trigger">
|
||||||
<img src="{{PhotoURL .Filename}}" loading="lazy"
|
<img src="{{PhotoURL .Filename}}" loading="lazy"
|
||||||
{{if BlurExplicit .}}class="blurred-explicit"{{end}}
|
{{if BlurExplicit .}}class="blurred-explicit"{{end}}
|
||||||
{{if .AltText}}alt="{{.AltText}}" title="{{.AltText}}"{{end}}>
|
{{if .AltText}}alt="{{.AltText}}" title="{{.AltText}}"{{end}}>
|
||||||
|
@ -557,7 +579,7 @@
|
||||||
{{template "card-body" .}}
|
{{template "card-body" .}}
|
||||||
|
|
||||||
<!-- Quick mark photo as explicit -->
|
<!-- Quick mark photo as explicit -->
|
||||||
{{if and (not .Explicit) (ne .UserID $Root.CurrentUser.ID)}}
|
{{if and (not .Explicit) (ne .UserID $Root.CurrentUser.ID) (not .HasAdminLabelNonExplicit)}}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<a href="#"
|
<a href="#"
|
||||||
class="has-text-danger is-size-7 nonshy-mark-explicit"
|
class="has-text-danger is-size-7 nonshy-mark-explicit"
|
||||||
|
@ -658,6 +680,8 @@
|
||||||
<!-- GIF video? -->
|
<!-- GIF video? -->
|
||||||
{{if HasSuffix .Filename ".mp4"}}
|
{{if HasSuffix .Filename ".mp4"}}
|
||||||
<video loop controls controlsList="nodownload" playsinline
|
<video loop controls controlsList="nodownload" playsinline
|
||||||
|
class="js-modal-trigger"
|
||||||
|
data-url="{{PhotoURL .Filename}}" data-photo-id="{{.ID}}"
|
||||||
{{if .AltText}}title="{{.AltText}}"{{end}}
|
{{if .AltText}}title="{{.AltText}}"{{end}}
|
||||||
{{if BlurExplicit .}}class="blurred-explicit"
|
{{if BlurExplicit .}}class="blurred-explicit"
|
||||||
{{else if (not (eq ($Root.CurrentUser.GetProfileField "autoplay_gif") "false"))}}autoplay
|
{{else if (not (eq ($Root.CurrentUser.GetProfileField "autoplay_gif") "false"))}}autoplay
|
||||||
|
@ -665,8 +689,8 @@
|
||||||
<source src="{{PhotoURL .Filename}}" type="video/mp4">
|
<source src="{{PhotoURL .Filename}}" type="video/mp4">
|
||||||
</video>
|
</video>
|
||||||
{{else}}
|
{{else}}
|
||||||
<a href="/photo/view?id={{.ID}}" data-url="{{PhotoURL .Filename}}" target="_blank"
|
<a href="/photo/view?id={{.ID}}" data-url="{{PhotoURL .Filename}}" data-photo-id="{{.ID}}" target="_blank"
|
||||||
class="js-modal-trigger" data-target="detail-modal">
|
class="js-modal-trigger">
|
||||||
<img src="{{PhotoURL .Filename}}" loading="lazy"
|
<img src="{{PhotoURL .Filename}}" loading="lazy"
|
||||||
{{if BlurExplicit .}}class="blurred-explicit"{{end}}
|
{{if BlurExplicit .}}class="blurred-explicit"{{end}}
|
||||||
{{if .AltText}}alt="{{.AltText}}" title="{{.AltText}}"{{end}}>
|
{{if .AltText}}alt="{{.AltText}}" title="{{.AltText}}"{{end}}>
|
||||||
|
@ -681,7 +705,7 @@
|
||||||
{{template "card-body" .}}
|
{{template "card-body" .}}
|
||||||
|
|
||||||
<!-- Quick mark photo as explicit -->
|
<!-- Quick mark photo as explicit -->
|
||||||
{{if and (not .Explicit) (ne .UserID $Root.CurrentUser.ID)}}
|
{{if and (not .Explicit) (ne .UserID $Root.CurrentUser.ID) (not .HasAdminLabelNonExplicit)}}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<a href="#"
|
<a href="#"
|
||||||
class="has-text-danger is-size-7 nonshy-mark-explicit"
|
class="has-text-danger is-size-7 nonshy-mark-explicit"
|
||||||
|
@ -740,6 +764,45 @@
|
||||||
|
|
||||||
{{SimplePager .Pager}}
|
{{SimplePager .Pager}}
|
||||||
|
|
||||||
|
<!-- Bulk user actions to their photos -->
|
||||||
|
{{if or .IsOwnPhotos (.CurrentUser.HasAdminScope "social.moderator.photo")}}
|
||||||
|
<hr>
|
||||||
|
<div class="columns is-multiline is-mobile my-4">
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<div class="buttons has-addons">
|
||||||
|
<button type="button" class="button" id="nonshy-select-all">
|
||||||
|
<i class="fa fa-square-check"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="button" id="nonshy-select-none">
|
||||||
|
<i class="fa fa-square"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column" id="nonshy-edit-buttons">
|
||||||
|
<button type="submit" class="button is-small is-danger is-outlined"
|
||||||
|
name="intent"
|
||||||
|
value="delete">
|
||||||
|
<i class="fa fa-trash mr-2"></i>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="submit" class="button mx-1 is-small is-info is-outlined"
|
||||||
|
name="intent"
|
||||||
|
value="visibility">
|
||||||
|
<i class="fa fa-eye mr-2"></i>
|
||||||
|
Edit Visibility
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span id="nonshy-count-selected" class="is-size-7 ml-2"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
</form><!-- end gallery form for batch edits -->
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Admin change log link -->
|
<!-- Admin change log link -->
|
||||||
{{if .CurrentUser.HasAdminScope "admin.changelog"}}
|
{{if .CurrentUser.HasAdminScope "admin.changelog"}}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
|
@ -751,11 +814,67 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
|
||||||
|
{{if or .IsOwnPhotos (.CurrentUser.HasAdminScope "social.moderator.photo")}}
|
||||||
|
// Batch edit controls
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const checkboxes = document.getElementsByClassName("nonshy-edit-photo-id"),
|
||||||
|
$checkAll = document.querySelector("#nonshy-select-all"),
|
||||||
|
$checkNone = document.querySelector("#nonshy-select-none"),
|
||||||
|
$countSelected = document.querySelector("#nonshy-count-selected"),
|
||||||
|
$submitButtons = document.querySelector("#nonshy-edit-buttons");
|
||||||
|
|
||||||
|
$submitButtons.style.display = "none";
|
||||||
|
|
||||||
|
const setAllChecked = (v) => {
|
||||||
|
for (let box of checkboxes) {
|
||||||
|
box.checked = v;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const areAnyChecked = () => {
|
||||||
|
let any = false,
|
||||||
|
count = 0;
|
||||||
|
for (let box of checkboxes) {
|
||||||
|
if (box.checked) {
|
||||||
|
any = true;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the selected count
|
||||||
|
$countSelected.innerHTML = count > 0 ? `${count} selected.` : "";
|
||||||
|
$countSelected.style.display = count > 0 ? "" : "none";
|
||||||
|
return any;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showHideButtons = () => {
|
||||||
|
$submitButtons.style.display = areAnyChecked() ? "" : "none";
|
||||||
|
};
|
||||||
|
showHideButtons();
|
||||||
|
|
||||||
|
// Check/Uncheck All buttons.
|
||||||
|
$checkAll.addEventListener("click", (e) => {
|
||||||
|
setAllChecked(true);
|
||||||
|
showHideButtons();
|
||||||
|
});
|
||||||
|
$checkNone.addEventListener("click", (e) => {
|
||||||
|
setAllChecked(false);
|
||||||
|
showHideButtons();
|
||||||
|
});
|
||||||
|
|
||||||
|
// When checkboxes are toggled.
|
||||||
|
for (let box of checkboxes) {
|
||||||
|
box.addEventListener("change", (e) => {
|
||||||
|
showHideButtons();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{{end}}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
// Get our modal to trigger it on click of a detail img.
|
// Get our modal to trigger it on click of a detail img.
|
||||||
let $modal = document.querySelector("#detail-modal"),
|
let $modal = document.querySelector("#detail-modal"),
|
||||||
|
@ -779,14 +898,60 @@
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markImageViewed(photoID) {
|
||||||
|
fetch(`/v1/photo/${photoID}/view`, {
|
||||||
|
method: "POST",
|
||||||
|
mode: "same-origin",
|
||||||
|
cache: "no-cache",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}).then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.StatusCode !== 200) {
|
||||||
|
console.error("When marking photo %d as viewed: status code %d: %s", photoID, data.StatusCode, data.data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}).catch(window.alert);
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll(".js-modal-trigger").forEach(node => {
|
document.querySelectorAll(".js-modal-trigger").forEach(node => {
|
||||||
let $img = node.getElementsByTagName("img"),
|
let $img = node.getElementsByTagName("img"),
|
||||||
|
$video = node.tagName === 'VIDEO' ? node : null,
|
||||||
|
photoID = node.dataset.photoId,
|
||||||
altText = $img[0] != undefined ? $img[0].alt : '';
|
altText = $img[0] != undefined ? $img[0].alt : '';
|
||||||
|
|
||||||
|
// Video (animated GIF) handlers.
|
||||||
|
if ($video !== null) {
|
||||||
|
|
||||||
|
// Log this video viewed if the user interacts with it in any way.
|
||||||
|
// Note: because videos don't open in the lightbox modal.
|
||||||
|
['pause', 'mouseover'].forEach(event => {
|
||||||
|
$video.addEventListener(event, (e) => {
|
||||||
|
// Log a view of this video.
|
||||||
|
markImageViewed(photoID);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images: open in the lightbox modal.
|
||||||
node.addEventListener("click", (e) => {
|
node.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setModalImage(node.dataset.url, altText);
|
setModalImage(node.dataset.url, altText);
|
||||||
$modal.classList.add("is-active");
|
$modal.classList.add("is-active");
|
||||||
})
|
|
||||||
|
// Log a view of this photo.
|
||||||
|
markImageViewed(photoID);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Images: count a mouseover as a view to be on par with videos, otherwise
|
||||||
|
// videos climb to the top of the most viewed list too quickly.
|
||||||
|
node.addEventListener("mouseover", (e) => {
|
||||||
|
markImageViewed(photoID);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user