Compare commits
No commits in common. "main" and "user-forums" have entirely different histories.
main
...
user-forum
104
README.md
104
README.md
|
@ -20,6 +20,20 @@ The website can also run out of a local SQLite database which is convenient
|
||||||
for local development. The production server runs on PostgreSQL and the
|
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
|
||||||
|
@ -47,96 +61,6 @@ 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,21 +261,6 @@ 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,10 +37,9 @@ 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, and now are considered to have a 'Shy Account.'<br><br>" +
|
Message: because + "you had updated your nonshy profile to become too private.<br><br>" +
|
||||||
"You may <strong>refresh</strong> the page to log back into chat as a Shy Account, where your ability to use webcams and share photos " +
|
"You may join the chat room after you have made your profile and (at least some) pictures " +
|
||||||
"will be restricted. To regain full access to the chat room, please edit your profile settings to make sure that at least one 'public' " +
|
"viewable on 'public' so that you won't appear to be a blank, faceless profile to others on the chat room.<br><br>" +
|
||||||
"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.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,37 +0,0 @@
|
||||||
package config
|
|
||||||
|
|
||||||
import "strings"
|
|
||||||
|
|
||||||
// Admin Labels.
|
|
||||||
const (
|
|
||||||
// Admin Labels for Photos
|
|
||||||
AdminLabelPhotoNonExplicit = "non-explicit"
|
|
||||||
AdminLabelPhotoForceExplicit = "force-explicit"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
AdminLabelPhotoOptions = []ChecklistOption{
|
|
||||||
{
|
|
||||||
Value: AdminLabelPhotoNonExplicit,
|
|
||||||
Label: "This is not an Explicit photo",
|
|
||||||
Help: "Hide the prompt 'Should this photo be marked as explicit?' as this photo does not NEED to be Explicit. " +
|
|
||||||
"Note: the owner of this photo MAY still mark it explicit if they want to.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Value: AdminLabelPhotoForceExplicit,
|
|
||||||
Label: "Force this photo to be marked as Explicit",
|
|
||||||
Help: "Enabling this option will force the Explicit tag to stay on, and not allow the user to remove it.",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// HasAdminLabel checks if a comma-separated set of admin labels contains the label.
|
|
||||||
func HasAdminLabel(needle string, haystack string) bool {
|
|
||||||
labels := strings.Split(haystack, ",")
|
|
||||||
for _, label := range labels {
|
|
||||||
if strings.TrimSpace(label) == needle {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
|
@ -25,10 +25,6 @@ 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
|
||||||
|
@ -42,11 +38,6 @@ 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
|
||||||
|
@ -60,11 +51,6 @@ 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
|
||||||
|
@ -92,9 +78,6 @@ 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 (
|
||||||
|
@ -130,12 +113,6 @@ 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,7 +79,6 @@ 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",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Choices for the Contact Us subject
|
// Choices for the Contact Us subject
|
||||||
|
@ -120,30 +119,6 @@ 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.
|
// ContactUs choices for the subject drop-down.
|
||||||
|
@ -158,13 +133,6 @@ 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 = 5
|
var currentVersion = 4
|
||||||
|
|
||||||
// Current loaded settings.json
|
// Current loaded settings.json
|
||||||
var Current = DefaultVariable()
|
var Current = DefaultVariable()
|
||||||
|
@ -32,7 +32,6 @@ 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
|
||||||
|
@ -127,12 +126,6 @@ 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.")
|
||||||
|
@ -203,12 +196,6 @@ 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: "read, created_at desc",
|
Sort: "created_at desc",
|
||||||
}
|
}
|
||||||
pager.ParsePage(r)
|
pager.ParsePage(r)
|
||||||
notifs, err := models.PaginateNotifications(currentUser, nf, pager)
|
notifs, err := models.PaginateNotifications(currentUser, nf, pager)
|
||||||
|
|
|
@ -3,9 +3,7 @@ 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"
|
||||||
|
@ -41,7 +39,6 @@ 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)
|
||||||
|
@ -73,9 +70,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 (photo moderator) can always see the profile pic - but only on this page.
|
// Admin user can always see the profile pic - but only on this page. Other avatar displays
|
||||||
// Other avatar displays will show the yellow or pink shy.png if the admin is not friends or not granted.
|
// will show the yellow or pink shy.png if the admin is not friends or not granted.
|
||||||
if currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
if currentUser.IsAdmin {
|
||||||
user.UserRelationship.IsFriend = true
|
user.UserRelationship.IsFriend = true
|
||||||
user.UserRelationship.IsPrivateGranted = true
|
user.UserRelationship.IsPrivateGranted = true
|
||||||
}
|
}
|
||||||
|
@ -104,14 +101,6 @@ 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,
|
||||||
|
@ -127,9 +116,6 @@ 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,9 +135,7 @@ func ForgotPassword() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email them their reset link -- if not banned.
|
// Email them their reset link.
|
||||||
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",
|
||||||
|
@ -149,12 +147,6 @@ 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,7 +6,6 @@ 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"
|
||||||
|
@ -45,7 +44,6 @@ 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
|
||||||
)
|
)
|
||||||
|
@ -151,17 +149,6 @@ 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,
|
||||||
|
@ -170,7 +157,6 @@ 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,
|
||||||
|
@ -227,7 +213,6 @@ 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.
|
||||||
|
|
|
@ -62,10 +62,6 @@ 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":
|
||||||
|
@ -224,7 +220,6 @@ 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
|
||||||
|
@ -237,7 +232,6 @@ 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)
|
||||||
|
@ -478,11 +472,9 @@ 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,13 +139,10 @@ func Signup() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already an account?
|
// Already an account?
|
||||||
if user, err := models.FindUser(email); err == nil {
|
if _, err := models.FindUser(email); err == nil {
|
||||||
// We don't want to admit that the email already is registered, so send an email to the
|
// 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 user.IsBanned() {
|
if err := mail.LockSending("signup", email, config.SignupTokenExpires); err == nil {
|
||||||
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",
|
||||||
|
@ -158,11 +155,9 @@ 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,10 +18,7 @@ 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 (
|
var username = r.PathValue("username")
|
||||||
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)
|
||||||
|
@ -111,7 +108,7 @@ func UserNotes() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paginate feedback & reports.
|
// Paginate feedback & reports.
|
||||||
if fb, err := models.PaginateFeedbackAboutUser(user, show, fbPager); err != nil {
|
if fb, err := models.PaginateFeedbackAboutUser(user, fbPager); err != nil {
|
||||||
session.FlashError(w, r, "Paginating feedback on this user: %s", err)
|
session.FlashError(w, r, "Paginating feedback on this user: %s", err)
|
||||||
} else {
|
} else {
|
||||||
feedback = fb
|
feedback = fb
|
||||||
|
@ -144,7 +141,6 @@ 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,13 +14,6 @@ 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 (
|
||||||
|
@ -30,26 +23,8 @@ 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)
|
||||||
|
@ -69,15 +44,6 @@ 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)
|
||||||
|
@ -90,29 +56,15 @@ 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)
|
||||||
// Going forward: the aboutUser will be populated, this is for legacy reports.
|
if err != nil {
|
||||||
if aboutUser == nil {
|
|
||||||
if user, err := models.GetUser(pic.UserID); err == nil {
|
|
||||||
aboutUser = user
|
|
||||||
} else {
|
|
||||||
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
|
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
|
||||||
}
|
} else {
|
||||||
}
|
templates.Redirect(w, "/u/"+user.Username)
|
||||||
|
|
||||||
if aboutUser != nil {
|
|
||||||
templates.Redirect(w, "/u/"+aboutUser.Username)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -137,9 +89,19 @@ func Feedback() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "comments":
|
case "comments":
|
||||||
// Redirect to the comment redirector.
|
// Get this comment.
|
||||||
templates.Redirect(w, fmt.Sprintf("/go/comment?id=%d", fb.TableID))
|
comment, err := models.GetComment(fb.TableID)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't get comment ID %d: %s", fb.TableID, err)
|
||||||
|
} else {
|
||||||
|
// What was the comment on?
|
||||||
|
switch comment.TableName {
|
||||||
|
case "threads":
|
||||||
|
// Visit the thread.
|
||||||
|
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", comment.TableID))
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
case "forums":
|
case "forums":
|
||||||
// Get this forum.
|
// Get this forum.
|
||||||
forum, err := models.GetForum(fb.TableID)
|
forum, err := models.GetForum(fb.TableID)
|
||||||
|
@ -187,51 +149,31 @@ func Feedback() http.HandlerFunc {
|
||||||
pager := &models.Pagination{
|
pager := &models.Pagination{
|
||||||
Page: 1,
|
Page: 1,
|
||||||
PerPage: config.PageSizeAdminFeedback,
|
PerPage: config.PageSizeAdminFeedback,
|
||||||
Sort: sort,
|
Sort: "updated_at desc",
|
||||||
}
|
}
|
||||||
pager.ParsePage(r)
|
pager.ParsePage(r)
|
||||||
page, err := models.PaginateFeedback(acknowledged, intent, subject, search, pager)
|
page, err := models.PaginateFeedback(acknowledged, intent, 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 (
|
var userIDs = []uint64{}
|
||||||
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,7 +52,6 @@ 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 {
|
||||||
|
@ -118,60 +117,11 @@ 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,9 +79,6 @@ 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",
|
||||||
|
@ -90,7 +87,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**](/u/%s)\n"+
|
"* Channel: **%s**\n"+
|
||||||
"* Timestamp: %s\n"+
|
"* Timestamp: %s\n"+
|
||||||
"* Classification: %s\n"+
|
"* Classification: %s\n"+
|
||||||
"* User comment: %s\n\n"+
|
"* User comment: %s\n\n"+
|
||||||
|
@ -98,7 +95,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, otherUsername,
|
report.Channel,
|
||||||
report.Timestamp,
|
report.Timestamp,
|
||||||
report.Reason,
|
report.Reason,
|
||||||
report.Comment,
|
report.Comment,
|
||||||
|
@ -119,7 +116,6 @@ 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,7 +8,6 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -99,18 +98,26 @@ 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.
|
||||||
if ok, _ := photo.ShouldBeSeenBy(currentUser); !ok {
|
var unallowed bool
|
||||||
|
if currentUser.ID != user.ID {
|
||||||
|
if (photo.Visibility == models.PhotoFriends && !models.AreFriends(user.ID, currentUser.ID)) ||
|
||||||
|
(photo.Visibility == models.PhotoPrivate && !models.IsPrivateUnlocked(user.ID, currentUser.ID)) {
|
||||||
|
unallowed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocking safety check: if either user blocks the other, liking is not allowed.
|
||||||
|
if models.IsBlocking(currentUser.ID, user.ID) {
|
||||||
|
unallowed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if unallowed {
|
||||||
SendJSON(w, http.StatusForbidden, Response{
|
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 {
|
||||||
|
@ -197,13 +204,6 @@ func Likes() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh cached like counts.
|
|
||||||
if req.TableName == "photos" {
|
|
||||||
if err := models.UpdatePhotoCachedCounts(tableID); err != nil {
|
|
||||||
log.Error("UpdatePhotoCachedCount(%d): %s", tableID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send success response.
|
// 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: photo.VisibleAvatarURL(user, currentUser),
|
Avatar: user.VisibleAvatarURL(currentUser),
|
||||||
Relationship: user.UserRelationship,
|
Relationship: user.UserRelationship,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -83,7 +83,6 @@ 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),
|
||||||
|
@ -91,23 +90,6 @@ 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 {
|
||||||
|
|
|
@ -1,70 +0,0 @@
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ViewPhoto API pings a view count on a photo, e.g. from the lightbox modal.
|
|
||||||
func ViewPhoto() http.HandlerFunc {
|
|
||||||
// Response JSON schema.
|
|
||||||
type Response struct {
|
|
||||||
OK bool `json:"OK"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
Likes int64 `json:"likes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Get the current user.
|
|
||||||
currentUser, err := session.CurrentUser(r)
|
|
||||||
if err != nil {
|
|
||||||
SendJSON(w, http.StatusBadRequest, Response{
|
|
||||||
Error: "Couldn't get current user!",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Photo ID from path parameter.
|
|
||||||
var photoID uint64
|
|
||||||
if id, err := strconv.Atoi(r.PathValue("photo_id")); err == nil && id > 0 {
|
|
||||||
photoID = uint64(id)
|
|
||||||
} else {
|
|
||||||
SendJSON(w, http.StatusBadRequest, Response{
|
|
||||||
Error: "Invalid photo ID",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find this photo.
|
|
||||||
photo, err := models.GetPhoto(photoID)
|
|
||||||
if err != nil {
|
|
||||||
SendJSON(w, http.StatusNotFound, Response{
|
|
||||||
Error: "Photo Not Found",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check permission to have seen this photo.
|
|
||||||
if ok, err := photo.ShouldBeSeenBy(currentUser); !ok {
|
|
||||||
log.Error("Photo %d can't be seen by %s: %s", photo.ID, currentUser.Username, err)
|
|
||||||
SendJSON(w, http.StatusNotFound, Response{
|
|
||||||
Error: "Photo Not Found",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark a view.
|
|
||||||
if err := photo.View(currentUser); err != nil {
|
|
||||||
log.Error("Update photo(%d) views: %s", photo.ID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send success response.
|
|
||||||
SendJSON(w, http.StatusOK, Response{
|
|
||||||
OK: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -1,106 +0,0 @@
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/encryption"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/photo"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PhotoSignAuth API protects paths like /static/photos/ to authenticated user requests only.
|
|
||||||
func PhotoSignAuth() http.HandlerFunc {
|
|
||||||
type Response struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Error string `json:",omitempty"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
}
|
|
||||||
|
|
||||||
logAndError := func(w http.ResponseWriter, m string, v ...interface{}) {
|
|
||||||
log.Debug("ERROR PhotoSignAuth: "+m, v...)
|
|
||||||
SendJSON(w, http.StatusForbidden, Response{
|
|
||||||
Error: fmt.Sprintf(m, v...),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// We only protect the /static/photos subpath.
|
|
||||||
// And check if the SignedPhoto feature is enabled and enforcing.
|
|
||||||
var originalURI = r.Header.Get("X-Original-URI")
|
|
||||||
if !config.Current.SignedPhoto.Enabled || !strings.HasPrefix(originalURI, config.PhotoWebPath) {
|
|
||||||
SendJSON(w, http.StatusOK, Response{
|
|
||||||
Success: true,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the base filename.
|
|
||||||
var filename = strings.TrimPrefix(
|
|
||||||
strings.SplitN(originalURI, config.PhotoWebPath, 2)[1],
|
|
||||||
"/",
|
|
||||||
)
|
|
||||||
filename = strings.SplitN(filename, "?", 2)[0] // inner query string too
|
|
||||||
|
|
||||||
// Parse the JWT token parameter from the original URL.
|
|
||||||
var token string
|
|
||||||
if path, err := url.Parse(originalURI); err == nil {
|
|
||||||
query := path.Query()
|
|
||||||
token = query.Get("jwt")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The JWT token is required from here on out.
|
|
||||||
if token == "" {
|
|
||||||
logAndError(w, "JWT token is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we're logged in and who the current username is.
|
|
||||||
var username string
|
|
||||||
if currentUser, err := session.CurrentUser(r); err == nil {
|
|
||||||
username = currentUser.Username
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the JWT token is correctly signed and not expired.
|
|
||||||
claims, ok, err := encryption.ValidateClaims(
|
|
||||||
token,
|
|
||||||
[]byte(config.Current.SignedPhoto.JWTSecret),
|
|
||||||
&photo.SignedPhotoClaims{},
|
|
||||||
)
|
|
||||||
if !ok || err != nil {
|
|
||||||
logAndError(w, "When validating JWT claims: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the claims to get data to validate this request.
|
|
||||||
c, ok := claims.(*photo.SignedPhotoClaims)
|
|
||||||
if !ok {
|
|
||||||
logAndError(w, "JWT claims were not the correct shape: %+v", claims)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Was the signature for our username? (Skip if for Anyone)
|
|
||||||
if !c.Anyone && c.Subject != username {
|
|
||||||
logAndError(w, "That token did not belong to you")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is the file name correct?
|
|
||||||
hash := photo.FilenameHash(filename)
|
|
||||||
if hash != c.FilenameHash {
|
|
||||||
logAndError(w, "Filename hash mismatch: fn=%s hash=%s jwt=%s", filename, hash, c.FilenameHash)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug("PhotoSignAuth: JWT Signature OK! fn=%s u=%s anyone=%v expires=%+v", filename, c.Subject, c.Anyone, c.ExpiresAt)
|
|
||||||
|
|
||||||
SendJSON(w, http.StatusOK, Response{
|
|
||||||
Success: true,
|
|
||||||
Username: username,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -112,35 +112,17 @@ func BlockUser() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the target user is an admin, log this to the admin reports page.
|
// Can't block admins who have the unblockable scope.
|
||||||
if user.IsAdmin {
|
if user.IsAdmin && user.HasAdminScope(config.ScopeUnblockable) {
|
||||||
// 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\n\n%s",
|
"* Username: %s\n* Tried to block: %s",
|
||||||
currentUser.Username,
|
currentUser.Username,
|
||||||
user.Username,
|
user.Username,
|
||||||
footer,
|
|
||||||
),
|
),
|
||||||
UserID: currentUser.ID,
|
UserID: currentUser.ID,
|
||||||
TableName: "users",
|
TableName: "users",
|
||||||
|
@ -150,13 +132,10 @@ 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,6 +3,7 @@ package chat
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
@ -10,7 +11,6 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Claims are the JWT claims for the BareRTC chat room.
|
// JWT claims.
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
// Custom claims.
|
// Custom claims.
|
||||||
IsAdmin bool `json:"op,omitempty"`
|
IsAdmin bool `json:"op,omitempty"`
|
||||||
|
@ -32,7 +32,6 @@ 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
|
||||||
|
@ -77,6 +76,16 @@ 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)
|
||||||
|
@ -89,7 +98,7 @@ func Landing() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avatar URL - masked if non-public.
|
// Avatar URL - masked if non-public.
|
||||||
avatar := photo.SignedPublicAvatarURL(currentUser.ProfilePhoto.CroppedFilename)
|
avatar := photo.URLPath(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"
|
||||||
|
@ -111,16 +120,6 @@ 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),
|
||||||
|
@ -129,11 +128,17 @@ func Landing() http.HandlerFunc {
|
||||||
Nickname: currentUser.NameOrUsername(),
|
Nickname: currentUser.NameOrUsername(),
|
||||||
Emoji: emoji,
|
Emoji: emoji,
|
||||||
Gender: Gender(currentUser),
|
Gender: Gender(currentUser),
|
||||||
VIP: isShy, // "shy accounts" use the "VIP" status for special icon in chat
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Rules: rules,
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
|
||||||
RegisteredClaims: encryption.StandardClaims(currentUser.ID, currentUser.Username, time.Now().Add(5*time.Minute)),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||||
|
Issuer: config.Title,
|
||||||
|
Subject: currentUser.Username,
|
||||||
|
ID: fmt.Sprintf("%d", currentUser.ID),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
token, err := encryption.SignClaims(claims, []byte(config.Current.BareRTC.JWTSecret))
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
ss, err := token.SignedString(secret)
|
||||||
if err != nil {
|
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)
|
||||||
|
@ -149,15 +154,8 @@ 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="+token)
|
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -121,14 +121,6 @@ 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
|
||||||
}
|
}
|
||||||
|
@ -182,13 +174,6 @@ 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,6 +77,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -87,23 +88,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: "threads.updated_at desc",
|
Sort: "updated_at desc",
|
||||||
}
|
}
|
||||||
pager.ParsePage(r)
|
pager.ParsePage(r)
|
||||||
|
|
||||||
|
|
|
@ -109,14 +109,7 @@ 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,13 +97,6 @@ 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,14 +26,12 @@ 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."
|
||||||
)
|
)
|
||||||
|
@ -57,7 +55,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
@ -68,7 +65,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
@ -78,43 +74,12 @@ 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)
|
||||||
}
|
}
|
||||||
|
@ -176,15 +141,11 @@ From: <a href="/u/%s">@%s</a>
|
||||||
fb := &models.Feedback{
|
fb := &models.Feedback{
|
||||||
Intent: intent,
|
Intent: intent,
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
Message: message + footer,
|
Message: message,
|
||||||
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 != "" {
|
||||||
|
|
|
@ -1,57 +0,0 @@
|
||||||
package index
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/models/demographic"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Demographics page (/insights) to show a peek at website demographics.
|
|
||||||
func Demographics() http.HandlerFunc {
|
|
||||||
tmpl := templates.Must("demographics.html")
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var (
|
|
||||||
refresh = r.FormValue("refresh") == "true"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Are we refreshing? Check if an admin is logged in.
|
|
||||||
if refresh {
|
|
||||||
currentUser, err := session.CurrentUser(r)
|
|
||||||
if err != nil {
|
|
||||||
session.FlashError(w, r, "You must be logged in to do that!")
|
|
||||||
templates.Redirect(w, r.URL.Path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do the refresh?
|
|
||||||
if currentUser.IsAdmin {
|
|
||||||
_, err := demographic.Refresh()
|
|
||||||
if err != nil {
|
|
||||||
session.FlashError(w, r, "Refreshing the insights: %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templates.Redirect(w, r.URL.Path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get website statistics to show on home page.
|
|
||||||
demo, err := demographic.Get()
|
|
||||||
if err != nil {
|
|
||||||
session.FlashError(w, r, "Couldn't get website statistics: %s", err)
|
|
||||||
templates.Redirect(w, "/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
vars := map[string]interface{}{
|
|
||||||
"Demographic": demo,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -5,7 +5,6 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -19,17 +18,7 @@ func Create() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get website statistics to show on home page.
|
if err := tmpl.Execute(w, r, nil); err != nil {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,235 +0,0 @@
|
||||||
package photo
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/chat"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
|
||||||
pphoto "code.nonshy.com/nonshy/website/pkg/photo"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BatchEdit controller (/photo/batch-edit?id=N) to change properties about your picture.
|
|
||||||
func BatchEdit() http.HandlerFunc {
|
|
||||||
tmpl := templates.Must("photo/batch_edit.html")
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var (
|
|
||||||
// Form params
|
|
||||||
intent = r.FormValue("intent")
|
|
||||||
photoIDs []uint64
|
|
||||||
)
|
|
||||||
|
|
||||||
// Collect the photo ID params.
|
|
||||||
if value, ok := r.Form["id"]; ok {
|
|
||||||
for _, idStr := range value {
|
|
||||||
if photoID, err := strconv.Atoi(idStr); err == nil {
|
|
||||||
photoIDs = append(photoIDs, uint64(photoID))
|
|
||||||
} else {
|
|
||||||
log.Error("parsing photo ID %s: %s", idStr, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validation.
|
|
||||||
if len(photoIDs) == 0 || len(photoIDs) > 100 {
|
|
||||||
session.FlashError(w, r, "Invalid number of photo IDs.")
|
|
||||||
templates.Redirect(w, "/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find these photos by ID.
|
|
||||||
photos, err := models.GetPhotos(photoIDs)
|
|
||||||
if err != nil {
|
|
||||||
templates.NotFoundPage(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the current user.
|
|
||||||
currentUser, err := session.CurrentUser(r)
|
|
||||||
if err != nil {
|
|
||||||
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
|
||||||
templates.Redirect(w, "/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate permission to edit all of these photos.
|
|
||||||
var (
|
|
||||||
ownerIDs []uint64
|
|
||||||
)
|
|
||||||
for _, photo := range photos {
|
|
||||||
|
|
||||||
if !photo.CanBeEditedBy(currentUser) {
|
|
||||||
templates.ForbiddenPage(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ownerIDs = append(ownerIDs, photo.UserID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the photo owners.
|
|
||||||
var (
|
|
||||||
owners, _ = models.MapUsers(currentUser, ownerIDs)
|
|
||||||
wasShy = map[uint64]bool{} // record if this change may make them shy
|
|
||||||
redirectURI = "/" // go first owner's gallery
|
|
||||||
|
|
||||||
// Are any of them a user's profile photo? (map userID->true) so we know
|
|
||||||
// who to unlink the picture from first and avoid a postgres error.
|
|
||||||
wasUserProfilePicture = map[uint64]bool{}
|
|
||||||
)
|
|
||||||
for _, user := range owners {
|
|
||||||
redirectURI = fmt.Sprintf("/u/%s/photos", user.Username)
|
|
||||||
wasShy[user.ID] = user.IsShy()
|
|
||||||
|
|
||||||
// Check if this user's profile ID is being deleted.
|
|
||||||
if user.ProfilePhotoID != nil {
|
|
||||||
if _, ok := photos[*user.ProfilePhotoID]; ok {
|
|
||||||
wasUserProfilePicture[user.ID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm batch deletion or edit.
|
|
||||||
if r.Method == http.MethodPost {
|
|
||||||
|
|
||||||
confirm := r.PostFormValue("confirm") == "true"
|
|
||||||
if !confirm {
|
|
||||||
session.FlashError(w, r, "Confirm you want to modify this photo.")
|
|
||||||
templates.Redirect(w, redirectURI)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Which intent are they executing on?
|
|
||||||
switch intent {
|
|
||||||
case "delete":
|
|
||||||
batchDeletePhotos(w, r, currentUser, photos, wasUserProfilePicture, owners, redirectURI)
|
|
||||||
case "visibility":
|
|
||||||
batchUpdateVisibility(w, r, currentUser, photos, owners)
|
|
||||||
default:
|
|
||||||
session.FlashError(w, r, "Unknown intent")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maybe kick them from chat if this deletion makes them into a Shy Account.
|
|
||||||
for _, user := range owners {
|
|
||||||
user.FlushCaches()
|
|
||||||
if !wasShy[user.ID] && user.IsShy() {
|
|
||||||
if _, err := chat.MaybeDisconnectUser(user); err != nil {
|
|
||||||
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the user to their gallery.
|
|
||||||
templates.Redirect(w, redirectURI)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
|
||||||
"Intent": intent,
|
|
||||||
"Photos": photos,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch DELETE executive handler.
|
|
||||||
func batchDeletePhotos(
|
|
||||||
w http.ResponseWriter,
|
|
||||||
r *http.Request,
|
|
||||||
currentUser *models.User,
|
|
||||||
photos map[uint64]*models.Photo,
|
|
||||||
wasUserProfilePicture map[uint64]bool,
|
|
||||||
owners map[uint64]*models.User,
|
|
||||||
redirectURI string,
|
|
||||||
) {
|
|
||||||
// Delete all the photos.
|
|
||||||
for _, photo := range photos {
|
|
||||||
|
|
||||||
// Was this someone's profile picture ID?
|
|
||||||
if wasUserProfilePicture[photo.UserID] {
|
|
||||||
log.Debug("Delete Photo: was the user's profile photo, unset ProfilePhotoID")
|
|
||||||
if owner, ok := owners[photo.UserID]; ok {
|
|
||||||
if err := owner.RemoveProfilePhoto(); err != nil {
|
|
||||||
session.FlashError(w, r, "Error unsetting your current profile photo: %s", err)
|
|
||||||
templates.Redirect(w, redirectURI)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the images from disk.
|
|
||||||
for _, filename := range []string{
|
|
||||||
photo.Filename,
|
|
||||||
photo.CroppedFilename,
|
|
||||||
} {
|
|
||||||
if len(filename) > 0 {
|
|
||||||
if err := pphoto.Delete(filename); err != nil {
|
|
||||||
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,10 +434,9 @@ 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 denied",
|
Subject: "Your certification photo has been rejected",
|
||||||
Template: "email/certification_rejected.html",
|
Template: "email/certification_rejected.html",
|
||||||
Data: map[string]interface{}{
|
Data: map[string]interface{}{
|
||||||
"Username": user.Username,
|
"Username": user.Username,
|
||||||
|
@ -447,9 +446,6 @@ 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!")
|
||||||
|
@ -507,7 +503,6 @@ 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!",
|
||||||
|
@ -519,9 +514,6 @@ 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,9 +71,6 @@ 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"))
|
||||||
|
@ -88,9 +85,6 @@ 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 {
|
||||||
|
@ -111,24 +105,6 @@ 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
|
||||||
|
@ -162,34 +138,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
@ -210,12 +158,9 @@ 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 {
|
||||||
|
@ -232,10 +177,6 @@ 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 {
|
||||||
|
@ -246,10 +187,121 @@ 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) {
|
||||||
templates.Redirect(w, fmt.Sprintf("/photo/batch-edit?intent=delete&id=%s", r.FormValue("id")))
|
// Query params.
|
||||||
|
photoID, err := strconv.Atoi(r.FormValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
log.Error("photo.Delete: failed to parse `id` param (%s) as int: %s", r.FormValue("id"), err)
|
||||||
|
session.FlashError(w, r, "Photo 'id' parameter required.")
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page to redirect to in case of errors.
|
||||||
|
redirect := fmt.Sprintf("%s?id=%d", r.URL.Path, photoID)
|
||||||
|
|
||||||
|
// Find this photo by ID.
|
||||||
|
photo, err := models.GetPhoto(uint64(photoID))
|
||||||
|
if err != nil {
|
||||||
|
templates.NotFoundPage(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the current user.
|
||||||
|
currentUser, err := session.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// In case an admin is editing this photo: remember the HTTP request current user,
|
||||||
|
// before the currentUser may be set to the photo's owner below.
|
||||||
|
var requestUser = currentUser
|
||||||
|
|
||||||
|
// Do we have permission for this photo?
|
||||||
|
if photo.UserID != currentUser.ID {
|
||||||
|
if !currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||||
|
templates.ForbiddenPage(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the owner of this photo and assume currentUser is them for the remainder
|
||||||
|
// of this controller.
|
||||||
|
if user, err := models.GetUser(photo.UserID); err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't get the owner User for this photo!")
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
currentUser = user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm deletion?
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
confirm := r.PostFormValue("confirm") == "true"
|
||||||
|
if !confirm {
|
||||||
|
session.FlashError(w, r, "Confirm you want to delete this photo.")
|
||||||
|
templates.Redirect(w, redirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Was this our profile picture?
|
||||||
|
if currentUser.ProfilePhotoID != nil && *currentUser.ProfilePhotoID == photo.ID {
|
||||||
|
log.Debug("Delete Photo: was the user's profile photo, unset ProfilePhotoID")
|
||||||
|
if err := currentUser.RemoveProfilePhoto(); err != nil {
|
||||||
|
session.FlashError(w, r, "Error unsetting your current profile photo: %s", err)
|
||||||
|
templates.Redirect(w, redirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the images from disk.
|
||||||
|
for _, filename := range []string{
|
||||||
|
photo.Filename,
|
||||||
|
photo.CroppedFilename,
|
||||||
|
} {
|
||||||
|
if len(filename) > 0 {
|
||||||
|
if err := pphoto.Delete(filename); err != nil {
|
||||||
|
log.Error("Delete Photo: couldn't remove file from disk: %s: %s", filename, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take back notifications on it.
|
||||||
|
models.RemoveNotification("photos", photo.ID)
|
||||||
|
|
||||||
|
if err := photo.Delete(); err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't delete photo: %s", err)
|
||||||
|
templates.Redirect(w, redirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogDeleted(currentUser, requestUser, "photos", photo.ID, "Deleted the photo.", photo)
|
||||||
|
|
||||||
|
session.Flash(w, r, "Photo deleted!")
|
||||||
|
|
||||||
|
// Maybe kick them from chat if this deletion makes them into a Shy Account.
|
||||||
|
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
|
||||||
|
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the user to their gallery.
|
||||||
|
templates.Redirect(w, "/u/"+currentUser.Username+"/photos")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var vars = map[string]interface{}{
|
||||||
|
"Photo": photo,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,12 +42,6 @@ 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 {
|
||||||
|
@ -64,12 +58,6 @@ 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)
|
||||||
|
@ -141,15 +129,6 @@ 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)
|
||||||
|
@ -185,21 +164,6 @@ 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,7 +4,6 @@ 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"
|
||||||
|
@ -18,9 +17,6 @@ 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) {
|
||||||
|
@ -125,13 +121,6 @@ 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,9 +19,6 @@ 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) {
|
||||||
|
@ -195,16 +192,12 @@ 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,12 +35,6 @@ func View() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the current user in case they are viewing their own page.
|
|
||||||
currentUser, err := session.CurrentUser(r)
|
|
||||||
if err != nil {
|
|
||||||
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the photo's owner.
|
// Find the photo's owner.
|
||||||
user, err := models.GetUser(photo.UserID)
|
user, err := models.GetUser(photo.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -48,10 +42,34 @@ func View() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok, err := photo.CanBeSeenBy(currentUser); !ok {
|
// Load the current user in case they are viewing their own page.
|
||||||
log.Error("Photo %d can't be seen by %s: %s", photo.ID, currentUser.Username, err)
|
currentUser, err := session.CurrentUser(r)
|
||||||
session.FlashError(w, r, "Photo Not Found")
|
if err != nil {
|
||||||
templates.Redirect(w, "/")
|
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
||||||
|
}
|
||||||
|
var isOwnPhoto = currentUser.ID == user.ID
|
||||||
|
|
||||||
|
// Is either one blocking?
|
||||||
|
if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
|
||||||
|
templates.NotFoundPage(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is this user private and we're not friends?
|
||||||
|
var (
|
||||||
|
areFriends = models.AreFriends(user.ID, currentUser.ID)
|
||||||
|
isPrivate = user.Visibility == models.UserVisibilityPrivate && !areFriends
|
||||||
|
)
|
||||||
|
if isPrivate && !currentUser.IsAdmin && !isOwnPhoto {
|
||||||
|
session.FlashError(w, r, "This user's profile page and photo gallery are private.")
|
||||||
|
templates.Redirect(w, "/u/"+user.Username)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is this a private photo and are we allowed to see?
|
||||||
|
isGranted := models.IsPrivateUnlocked(user.ID, currentUser.ID)
|
||||||
|
if photo.Visibility == models.PhotoPrivate && !isGranted && !isOwnPhoto && !currentUser.IsAdmin {
|
||||||
|
templates.NotFoundPage(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,11 +102,6 @@ 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,
|
||||||
|
|
|
@ -1,71 +0,0 @@
|
||||||
package encryption
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
|
||||||
"github.com/golang-jwt/jwt/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StandardClaims returns a standard JWT claim for a username.
|
|
||||||
//
|
|
||||||
// It will include values for Subject (username), Issuer (site title), ExpiresAt, IssuedAt, NotBefore.
|
|
||||||
//
|
|
||||||
// If the userID is >0, the ID field is included.
|
|
||||||
func StandardClaims(userID uint64, username string, expiresAt time.Time) jwt.RegisteredClaims {
|
|
||||||
claim := jwt.RegisteredClaims{
|
|
||||||
Subject: username,
|
|
||||||
Issuer: config.Title,
|
|
||||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
||||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
|
||||||
}
|
|
||||||
if userID > 0 {
|
|
||||||
claim.ID = fmt.Sprintf("%d", userID)
|
|
||||||
}
|
|
||||||
return claim
|
|
||||||
}
|
|
||||||
|
|
||||||
// SignClaims creates and returns a signed JWT token.
|
|
||||||
func SignClaims(claims jwt.Claims, secret []byte) (string, error) {
|
|
||||||
// Get our Chat JWT secret.
|
|
||||||
if len(secret) == 0 {
|
|
||||||
return "", errors.New("JWT secret key is not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
ss, err := token.SignedString(secret)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return ss, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateClaims checks a JWT token is signed by the site key and returns the claims.
|
|
||||||
func ValidateClaims(tokenStr string, secret []byte, v jwt.Claims) (jwt.Claims, bool, error) {
|
|
||||||
// Handle a JWT authentication token.
|
|
||||||
var (
|
|
||||||
claims jwt.Claims
|
|
||||||
authOK bool
|
|
||||||
)
|
|
||||||
if tokenStr != "" {
|
|
||||||
token, err := jwt.ParseWithClaims(tokenStr, v, func(token *jwt.Token) (interface{}, error) {
|
|
||||||
return secret, nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !token.Valid {
|
|
||||||
return nil, false, errors.New("token was not valid")
|
|
||||||
}
|
|
||||||
|
|
||||||
claims = token.Claims
|
|
||||||
authOK = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return claims, authOK, nil
|
|
||||||
}
|
|
|
@ -48,8 +48,9 @@ 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.PingLastLoginAt(); err != nil {
|
if err := user.Save(); 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
package backfill
|
|
||||||
|
|
||||||
import (
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BackfillPhotoCounts recomputes the cached Likes and Comment counts on photos.
|
|
||||||
func BackfillPhotoCounts() error {
|
|
||||||
res := models.DB.Exec(`
|
|
||||||
UPDATE photos
|
|
||||||
SET like_count = (
|
|
||||||
SELECT count(id)
|
|
||||||
FROM likes
|
|
||||||
WHERE table_name='photos'
|
|
||||||
AND table_id=photos.id
|
|
||||||
),
|
|
||||||
comment_count = (
|
|
||||||
SELECT count(id)
|
|
||||||
FROM comments
|
|
||||||
WHERE table_name='photos'
|
|
||||||
AND table_id=photos.id
|
|
||||||
);
|
|
||||||
`)
|
|
||||||
return res.Error
|
|
||||||
}
|
|
|
@ -199,7 +199,6 @@ 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.
|
||||||
|
@ -219,7 +218,6 @@ 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(
|
||||||
|
@ -227,7 +225,6 @@ 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 {
|
||||||
|
@ -236,7 +233,6 @@ 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -249,7 +245,6 @@ 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -258,7 +253,6 @@ 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -274,7 +268,6 @@ 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:idx_comment_composite"`
|
TableName string `gorm:"index"`
|
||||||
TableID uint64 `gorm:"index:idx_comment_composite"`
|
TableID uint64 `gorm:"index"`
|
||||||
UserID uint64 `gorm:"index"`
|
UserID uint64 `gorm:"index"`
|
||||||
User User `json:"-"`
|
User User `json:"-"`
|
||||||
Message string
|
Message string
|
||||||
|
|
|
@ -59,7 +59,6 @@ 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 {
|
||||||
|
@ -407,13 +406,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,196 +0,0 @@
|
||||||
// Package demographic handles periodic report pulling for high level website statistics.
|
|
||||||
//
|
|
||||||
// It powers the home page and insights page, where a prospective new user can get a peek inside
|
|
||||||
// the website to see the split between regular vs. explicit content and membership statistics.
|
|
||||||
//
|
|
||||||
// These database queries could get slow so the demographics are pulled and cached in this package.
|
|
||||||
package demographic
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"sort"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/utility"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Demographic is the top level container struct with all the insights needed for front-end display.
|
|
||||||
type Demographic struct {
|
|
||||||
Computed bool
|
|
||||||
LastUpdated time.Time
|
|
||||||
Photo Photo
|
|
||||||
People People
|
|
||||||
}
|
|
||||||
|
|
||||||
// Photo statistics show the split between explicit and non-explicit content.
|
|
||||||
type Photo struct {
|
|
||||||
Total int64
|
|
||||||
NonExplicit int64
|
|
||||||
Explicit int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// People statistics.
|
|
||||||
type People struct {
|
|
||||||
Total int64
|
|
||||||
ExplicitOptIn int64
|
|
||||||
ExplicitPhoto int64
|
|
||||||
ByAgeRange map[string]int64
|
|
||||||
ByGender map[string]int64
|
|
||||||
ByOrientation map[string]int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// MemberDemographic of members.
|
|
||||||
type MemberDemographic struct {
|
|
||||||
Label string // e.g. age range "18-25" or gender
|
|
||||||
Count int64
|
|
||||||
Percent string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dynamic calculation methods on the above types (percentages, etc.)
|
|
||||||
*/
|
|
||||||
|
|
||||||
func (d Demographic) PrettyPrint() string {
|
|
||||||
b, err := json.MarshalIndent(d, "", "\t")
|
|
||||||
if err != nil {
|
|
||||||
return err.Error()
|
|
||||||
}
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p Photo) PercentExplicit() string {
|
|
||||||
if p.Total == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
return utility.FormatFloatToPrecision((float64(p.Explicit)/float64(p.Total))*100, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p Photo) PercentNonExplicit() string {
|
|
||||||
if p.Total == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
return utility.FormatFloatToPrecision((float64(p.NonExplicit)/float64(p.Total))*100, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p People) PercentExplicit() string {
|
|
||||||
if p.Total == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
return utility.FormatFloatToPrecision((float64(p.ExplicitOptIn)/float64(p.Total))*100, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p People) PercentExplicitPhoto() string {
|
|
||||||
if p.Total == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
return utility.FormatFloatToPrecision((float64(p.ExplicitPhoto)/float64(p.Total))*100, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p People) IterAgeRanges() []MemberDemographic {
|
|
||||||
var (
|
|
||||||
result = []MemberDemographic{}
|
|
||||||
values = []string{}
|
|
||||||
unique = map[string]struct{}{}
|
|
||||||
)
|
|
||||||
|
|
||||||
for age := range p.ByAgeRange {
|
|
||||||
if _, ok := unique[age]; !ok {
|
|
||||||
values = append(values, age)
|
|
||||||
}
|
|
||||||
unique[age] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Strings(values)
|
|
||||||
for _, age := range values {
|
|
||||||
var (
|
|
||||||
count = p.ByAgeRange[age]
|
|
||||||
pct float64
|
|
||||||
)
|
|
||||||
if p.Total > 0 {
|
|
||||||
pct = ((float64(count) / float64(p.Total)) * 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
result = append(result, MemberDemographic{
|
|
||||||
Label: age,
|
|
||||||
Count: p.ByAgeRange[age],
|
|
||||||
Percent: utility.FormatFloatToPrecision(pct, 1),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p People) IterGenders() []MemberDemographic {
|
|
||||||
var (
|
|
||||||
result = []MemberDemographic{}
|
|
||||||
values = append(config.Gender, "")
|
|
||||||
unique = map[string]struct{}{}
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, option := range values {
|
|
||||||
unique[option] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
for gender := range p.ByGender {
|
|
||||||
if _, ok := unique[gender]; !ok {
|
|
||||||
values = append(values, gender)
|
|
||||||
unique[gender] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, gender := range values {
|
|
||||||
var (
|
|
||||||
count = p.ByGender[gender]
|
|
||||||
pct float64
|
|
||||||
)
|
|
||||||
if p.Total > 0 {
|
|
||||||
pct = ((float64(count) / float64(p.Total)) * 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
result = append(result, MemberDemographic{
|
|
||||||
Label: gender,
|
|
||||||
Count: p.ByGender[gender],
|
|
||||||
Percent: utility.FormatFloatToPrecision(pct, 1),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p People) IterOrientations() []MemberDemographic {
|
|
||||||
var (
|
|
||||||
result = []MemberDemographic{}
|
|
||||||
values = append(config.Orientation, "")
|
|
||||||
unique = map[string]struct{}{}
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, option := range values {
|
|
||||||
unique[option] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
for orientation := range p.ByOrientation {
|
|
||||||
if _, ok := unique[orientation]; !ok {
|
|
||||||
values = append(values, orientation)
|
|
||||||
unique[orientation] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, gender := range values {
|
|
||||||
var (
|
|
||||||
count = p.ByOrientation[gender]
|
|
||||||
pct float64
|
|
||||||
)
|
|
||||||
if p.Total > 0 {
|
|
||||||
pct = ((float64(count) / float64(p.Total)) * 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
result = append(result, MemberDemographic{
|
|
||||||
Label: gender,
|
|
||||||
Count: p.ByOrientation[gender],
|
|
||||||
Percent: utility.FormatFloatToPrecision(pct, 1),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
|
@ -1,302 +0,0 @@
|
||||||
package demographic
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cached statistics (in case the queries are heavy to hit too often).
|
|
||||||
var (
|
|
||||||
cachedDemographic Demographic
|
|
||||||
cacheMu sync.Mutex
|
|
||||||
)
|
|
||||||
|
|
||||||
// Get the current cached demographics result.
|
|
||||||
func Get() (Demographic, error) {
|
|
||||||
// Do we have the results cached?
|
|
||||||
var result = cachedDemographic
|
|
||||||
if !result.Computed || time.Since(result.LastUpdated) > config.DemographicsCacheTTL {
|
|
||||||
cacheMu.Lock()
|
|
||||||
defer cacheMu.Unlock()
|
|
||||||
|
|
||||||
// If we have a race of threads: e.g. one request is pulling the stats and the second is locked.
|
|
||||||
// Check if we have an updated result from the first thread.
|
|
||||||
if time.Since(cachedDemographic.LastUpdated) < config.DemographicsCacheTTL {
|
|
||||||
return cachedDemographic, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the latest.
|
|
||||||
res, err := Generate()
|
|
||||||
if err != nil {
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
cachedDemographic = res
|
|
||||||
}
|
|
||||||
|
|
||||||
return cachedDemographic, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the demographics cache, pulling fresh results from the database every time.
|
|
||||||
func Refresh() (Demographic, error) {
|
|
||||||
cacheMu.Lock()
|
|
||||||
cachedDemographic = Demographic{}
|
|
||||||
cacheMu.Unlock()
|
|
||||||
return Get()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate the demographics result.
|
|
||||||
func Generate() (Demographic, error) {
|
|
||||||
if !config.Current.Database.IsPostgres {
|
|
||||||
return cachedDemographic, errors.New("this feature requires a PostgreSQL database")
|
|
||||||
}
|
|
||||||
|
|
||||||
result := Demographic{
|
|
||||||
Computed: true,
|
|
||||||
LastUpdated: time.Now(),
|
|
||||||
Photo: PhotoStatistics(),
|
|
||||||
People: PeopleStatistics(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PeopleStatistics pulls various metrics about users of the website.
|
|
||||||
func PeopleStatistics() People {
|
|
||||||
var result = People{
|
|
||||||
ByAgeRange: map[string]int64{},
|
|
||||||
ByGender: map[string]int64{"": 0},
|
|
||||||
ByOrientation: map[string]int64{"": 0},
|
|
||||||
}
|
|
||||||
|
|
||||||
type record struct {
|
|
||||||
MetricType string
|
|
||||||
MetricValue string
|
|
||||||
MetricCount int64
|
|
||||||
}
|
|
||||||
var records []record
|
|
||||||
res := models.DB.Raw(`
|
|
||||||
-- Users who opt in/out of explicit content
|
|
||||||
WITH subquery_explicit AS (
|
|
||||||
SELECT
|
|
||||||
SUM(CASE WHEN explicit IS TRUE THEN 1 ELSE 0 END) AS explicit_count,
|
|
||||||
SUM(CASE WHEN explicit IS NOT TRUE THEN 1 ELSE 0 END) AS non_explicit_count
|
|
||||||
FROM users
|
|
||||||
WHERE users.status = 'active'
|
|
||||||
AND users.certified IS TRUE
|
|
||||||
),
|
|
||||||
|
|
||||||
-- Users who share at least one explicit photo on public
|
|
||||||
subquery_explicit_photo AS (
|
|
||||||
SELECT
|
|
||||||
COUNT(*) AS user_count
|
|
||||||
FROM users
|
|
||||||
WHERE users.status = 'active'
|
|
||||||
AND users.certified IS TRUE
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM photos
|
|
||||||
WHERE photos.user_id = users.id
|
|
||||||
AND photos.explicit IS TRUE
|
|
||||||
AND photos.visibility = 'public'
|
|
||||||
)
|
|
||||||
),
|
|
||||||
|
|
||||||
-- User counts by age
|
|
||||||
subquery_ages AS (
|
|
||||||
SELECT
|
|
||||||
CASE
|
|
||||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 0 AND 25 THEN '18-25'
|
|
||||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 26 and 35 THEN '26-35'
|
|
||||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 36 and 45 THEN '36-45'
|
|
||||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 46 and 55 THEN '46-55'
|
|
||||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 56 and 65 THEN '56-65'
|
|
||||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 66 and 75 THEN '66-75'
|
|
||||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 76 and 85 THEN '76-85'
|
|
||||||
ELSE '86+'
|
|
||||||
END AS age_range,
|
|
||||||
COUNT(*) AS user_count
|
|
||||||
FROM
|
|
||||||
users
|
|
||||||
WHERE users.status = 'active'
|
|
||||||
AND users.certified IS TRUE
|
|
||||||
GROUP BY
|
|
||||||
CASE
|
|
||||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 0 AND 25 THEN '18-25'
|
|
||||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 26 and 35 THEN '26-35'
|
|
||||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 36 and 45 THEN '36-45'
|
|
||||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 46 and 55 THEN '46-55'
|
|
||||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 56 and 65 THEN '56-65'
|
|
||||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 66 and 75 THEN '66-75'
|
|
||||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 76 and 85 THEN '76-85'
|
|
||||||
ELSE '86+'
|
|
||||||
END
|
|
||||||
),
|
|
||||||
|
|
||||||
-- User counts by gender
|
|
||||||
subquery_gender AS (
|
|
||||||
SELECT
|
|
||||||
profile_fields.value AS gender,
|
|
||||||
COUNT(*) AS user_count
|
|
||||||
FROM users
|
|
||||||
JOIN profile_fields ON profile_fields.user_id = users.id
|
|
||||||
WHERE users.status = 'active'
|
|
||||||
AND users.certified IS TRUE
|
|
||||||
AND profile_fields.name = 'gender'
|
|
||||||
GROUP BY profile_fields.value
|
|
||||||
),
|
|
||||||
|
|
||||||
-- User counts by orientation
|
|
||||||
subquery_orientation AS (
|
|
||||||
SELECT
|
|
||||||
profile_fields.value AS orientation,
|
|
||||||
COUNT(*) AS user_count
|
|
||||||
FROM users
|
|
||||||
JOIN profile_fields ON profile_fields.user_id = users.id
|
|
||||||
WHERE users.status = 'active'
|
|
||||||
AND users.certified IS TRUE
|
|
||||||
AND profile_fields.name = 'orientation'
|
|
||||||
GROUP BY profile_fields.value
|
|
||||||
)
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
'ExplicitCount' AS metric_type,
|
|
||||||
'explicit' AS metric_value,
|
|
||||||
explicit_count AS metric_count
|
|
||||||
FROM subquery_explicit
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
'ExplicitPhotoCount' AS metric_type,
|
|
||||||
'count' AS metric_value,
|
|
||||||
user_count AS metric_count
|
|
||||||
FROM subquery_explicit_photo
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
'ExplicitCount' AS metric_type,
|
|
||||||
'non_explicit' AS metric_value,
|
|
||||||
non_explicit_count AS metric_count
|
|
||||||
FROM subquery_explicit
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
'AgeCounts' AS metric_type,
|
|
||||||
age_range AS metric_value,
|
|
||||||
user_count AS metric_count
|
|
||||||
FROM subquery_ages
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
'GenderCount' AS metric_type,
|
|
||||||
gender AS metric_value,
|
|
||||||
user_count AS metric_count
|
|
||||||
FROM subquery_gender
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
'OrientationCount' AS metric_type,
|
|
||||||
orientation AS metric_value,
|
|
||||||
user_count AS metric_count
|
|
||||||
FROM subquery_orientation
|
|
||||||
`).Scan(&records)
|
|
||||||
if res.Error != nil {
|
|
||||||
log.Error("PeopleStatistics: %s", res.Error)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ingest the records.
|
|
||||||
var (
|
|
||||||
totalWithAge int64 // will be the total count of users since age is required
|
|
||||||
totalWithGender int64
|
|
||||||
totalWithOrientation int64
|
|
||||||
)
|
|
||||||
for _, row := range records {
|
|
||||||
switch row.MetricType {
|
|
||||||
case "ExplicitCount":
|
|
||||||
result.Total += row.MetricCount
|
|
||||||
if row.MetricValue == "explicit" {
|
|
||||||
result.ExplicitOptIn = row.MetricCount
|
|
||||||
}
|
|
||||||
case "ExplicitPhotoCount":
|
|
||||||
result.ExplicitPhoto = row.MetricCount
|
|
||||||
case "AgeCounts":
|
|
||||||
if _, ok := result.ByAgeRange[row.MetricValue]; !ok {
|
|
||||||
result.ByAgeRange[row.MetricValue] = 0
|
|
||||||
}
|
|
||||||
result.ByAgeRange[row.MetricValue] += row.MetricCount
|
|
||||||
totalWithAge += row.MetricCount
|
|
||||||
case "GenderCount":
|
|
||||||
if _, ok := result.ByGender[row.MetricValue]; !ok {
|
|
||||||
result.ByGender[row.MetricValue] = 0
|
|
||||||
}
|
|
||||||
result.ByGender[row.MetricValue] += row.MetricCount
|
|
||||||
totalWithGender += row.MetricCount
|
|
||||||
case "OrientationCount":
|
|
||||||
if _, ok := result.ByOrientation[row.MetricValue]; !ok {
|
|
||||||
result.ByOrientation[row.MetricValue] = 0
|
|
||||||
}
|
|
||||||
result.ByOrientation[row.MetricValue] += row.MetricCount
|
|
||||||
totalWithOrientation += row.MetricCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gender and Orientation: pad out the "no answer" selection with the count of users
|
|
||||||
// who had no profile_fields stored in the DB at all.
|
|
||||||
result.ByOrientation[""] += (totalWithAge - totalWithOrientation)
|
|
||||||
result.ByGender[""] += (totalWithAge - totalWithGender)
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// PhotoStatistics gets info about photo usage on the website.
|
|
||||||
//
|
|
||||||
// Counts of Explicit vs. Non-Explicit photos.
|
|
||||||
func PhotoStatistics() Photo {
|
|
||||||
var result Photo
|
|
||||||
type record struct {
|
|
||||||
Explicit bool
|
|
||||||
C int64
|
|
||||||
}
|
|
||||||
var records []record
|
|
||||||
|
|
||||||
res := models.DB.Raw(`
|
|
||||||
SELECT
|
|
||||||
photos.explicit,
|
|
||||||
count(photos.id) AS c
|
|
||||||
FROM
|
|
||||||
photos
|
|
||||||
JOIN users ON (photos.user_id = users.id)
|
|
||||||
WHERE photos.visibility = 'public'
|
|
||||||
AND photos.gallery IS TRUE
|
|
||||||
AND users.certified IS TRUE
|
|
||||||
AND users.status = 'active'
|
|
||||||
GROUP BY photos.explicit
|
|
||||||
ORDER BY c DESC
|
|
||||||
`).Scan(&records)
|
|
||||||
if res.Error != nil {
|
|
||||||
log.Error("PhotoStatistics: %s", res.Error)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, row := range records {
|
|
||||||
result.Total += row.C
|
|
||||||
if row.Explicit {
|
|
||||||
result.Explicit += row.C
|
|
||||||
} else {
|
|
||||||
result.NonExplicit += row.C
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
|
@ -19,33 +19,30 @@ 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{
|
||||||
// Note: AdminGroup info is eager-loaded in User export
|
{"User", ExportUserTable},
|
||||||
{"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},
|
||||||
{"PrivatePhoto", ExportPrivatePhotoTable},
|
// Note: AdminGroup info is eager-loaded in User export
|
||||||
{"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)
|
||||||
|
@ -447,48 +444,3 @@ 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,7 +1,6 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -12,7 +11,6 @@ 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
|
||||||
|
@ -47,7 +45,7 @@ func CountUnreadFeedback() int64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaginateFeedback
|
// PaginateFeedback
|
||||||
func PaginateFeedback(acknowledged bool, intent, subject string, search *Search, pager *Pagination) ([]*Feedback, error) {
|
func PaginateFeedback(acknowledged bool, intent string, pager *Pagination) ([]*Feedback, error) {
|
||||||
var (
|
var (
|
||||||
fb = []*Feedback{}
|
fb = []*Feedback{}
|
||||||
wheres = []string{}
|
wheres = []string{}
|
||||||
|
@ -62,23 +60,6 @@ func PaginateFeedback(acknowledged bool, intent, subject string, search *Search,
|
||||||
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...,
|
||||||
|
@ -100,49 +81,19 @@ func PaginateFeedback(acknowledged bool, intent, subject string, search *Search,
|
||||||
// It returns reports where table_name=users and their user ID, or where table_name=photos and about any
|
// 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, user.ID, photoIDs)
|
placeholders = append(placeholders, 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 "),
|
||||||
|
@ -160,22 +111,6 @@ func PaginateFeedbackAboutUser(user *User, show string, pager *Pagination) ([]*F
|
||||||
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,10 +140,7 @@ 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 (
|
OR forums.owner_id = ?
|
||||||
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:idx_likes_composite"`
|
TableName string `gorm:"index"`
|
||||||
TableID uint64 `gorm:"index:idx_likes_composite"`
|
TableID uint64 `gorm:"index"`
|
||||||
CreatedAt time.Time `gorm:"index"`
|
CreatedAt time.Time `gorm:"index"`
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
|
@ -169,16 +169,6 @@ 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,10 +31,9 @@ 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{}, // ✔
|
||||||
&UsageStatistic{}, // ✔
|
&Subscription{}, // ✔
|
||||||
&User{}, // ✔
|
&User{}, // ✔
|
||||||
&UserLocation{}, // ✔
|
&UserLocation{}, // ✔
|
||||||
&UserNote{}, // ✔
|
&UserNote{}, // ✔
|
||||||
|
|
|
@ -46,7 +46,6 @@ 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,13 +85,7 @@ 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,
|
types = append(types, NotificationFriendApproved, NotificationCertApproved, NotificationCertRejected, NotificationCustom)
|
||||||
NotificationFriendApproved,
|
|
||||||
NotificationCertApproved,
|
|
||||||
NotificationCertRejected,
|
|
||||||
NotificationExplicitPhoto,
|
|
||||||
NotificationCustom,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "type IN ?", types, true
|
return "type IN ?", types, true
|
||||||
|
|
|
@ -8,7 +8,6 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -22,14 +21,10 @@ 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
|
||||||
}
|
}
|
||||||
|
@ -108,71 +103,6 @@ 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"
|
||||||
|
@ -222,35 +152,6 @@ 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
|
||||||
|
@ -286,69 +187,6 @@ 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.
|
||||||
|
|
||||||
|
@ -451,11 +289,6 @@ func CountPhotosICanSee(user *User, viewer *User) int64 {
|
||||||
// MapPhotoCounts returns a mapping of user ID to the CountPhotos()-equivalent result for each.
|
// 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{}
|
||||||
|
@ -476,7 +309,7 @@ func MapPhotoCountsByVisibility(users []*User, visibility PhotoVisibility) Photo
|
||||||
).Select(
|
).Select(
|
||||||
"user_id, count(id) AS photo_count",
|
"user_id, count(id) AS photo_count",
|
||||||
).Where(
|
).Where(
|
||||||
"user_id IN ? AND visibility = ?", userIDs, visibility,
|
"user_id IN ? AND visibility = ?", userIDs, PhotoPublic,
|
||||||
).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)
|
||||||
}
|
}
|
||||||
|
@ -595,21 +428,16 @@ 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,
|
||||||
visibility,
|
PhotoPublic,
|
||||||
)
|
)
|
||||||
|
|
||||||
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("CountUserPhotosByVisibility(%d, %s): %s", userID, visibility, result.Error)
|
log.Error("CountPublicPhotos(%d): %s", userID, result.Error)
|
||||||
}
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
@ -644,13 +472,6 @@ 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
|
||||||
|
@ -724,9 +545,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("(photos.user_id IN %s AND photos.visibility IN ?)", friendsQuery),
|
fmt.Sprintf("(user_id IN %s AND visibility IN ?)", friendsQuery),
|
||||||
"(photos.user_id IN ? AND photos.visibility IN ?)",
|
"(user_id IN ? AND visibility IN ?)",
|
||||||
"photos.user_id = ?",
|
"user_id = ?",
|
||||||
)
|
)
|
||||||
visPlaceholders = append(visPlaceholders,
|
visPlaceholders = append(visPlaceholders,
|
||||||
photosFriends,
|
photosFriends,
|
||||||
|
@ -736,23 +557,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("(photos.user_id IN %s AND photos.visibility IN ?)", friendsQuery),
|
fmt.Sprintf("(user_id IN %s AND visibility IN ?)", friendsQuery),
|
||||||
"photos.user_id = ?",
|
"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, "(photos.user_id IN ? AND photos.visibility IN ?)")
|
visOrs = append(visOrs, "(user_id IN ? AND 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("(photos.user_id IN %s AND photos.visibility IN ?)", friendsQuery),
|
fmt.Sprintf("(user_id IN %s AND visibility IN ?)", friendsQuery),
|
||||||
"(photos.user_id IN ? AND photos.visibility IN ?)",
|
"(user_id IN ? AND visibility IN ?)",
|
||||||
fmt.Sprintf("(photos.user_id NOT IN %s AND photos.visibility IN ?)", friendsQuery),
|
fmt.Sprintf("(user_id NOT IN %s AND visibility IN ?)", friendsQuery),
|
||||||
"photos.user_id = ?",
|
"user_id = ?",
|
||||||
)
|
)
|
||||||
visPlaceholders = append(placeholders,
|
visPlaceholders = append(placeholders,
|
||||||
photosFriends,
|
photosFriends,
|
||||||
|
@ -767,7 +588,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, "photos.gallery = ?")
|
wheres = append(wheres, "gallery = ?")
|
||||||
placeholders = append(placeholders, true)
|
placeholders = append(placeholders, true)
|
||||||
|
|
||||||
// Filter by photos the user has liked.
|
// Filter by photos the user has liked.
|
||||||
|
@ -776,9 +597,9 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM likes
|
FROM likes
|
||||||
WHERE likes.user_id = ?
|
WHERE user_id = ?
|
||||||
AND likes.table_name = 'photos'
|
AND table_name = 'photos'
|
||||||
AND likes.table_id = photos.id
|
AND table_id = photos.id
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
placeholders = append(placeholders, user.ID)
|
placeholders = append(placeholders, user.ID)
|
||||||
|
@ -786,39 +607,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, "photos.user_id NOT IN ?")
|
wheres = append(wheres, "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, "photos.explicit = ?")
|
wheres = append(wheres, "explicit = ?")
|
||||||
placeholders = append(placeholders, filterExplicit == "true")
|
placeholders = append(placeholders, filterExplicit == "true")
|
||||||
} else if !explicitOK {
|
} else if !explicitOK {
|
||||||
wheres = append(wheres, "photos.explicit = ?")
|
wheres = append(wheres, "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, "photos.visibility = ?")
|
wheres = append(wheres, "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 users.certified IS NOT true AND users.status='active')",
|
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND certified IS NOT true AND status='active')",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
wheres = append(wheres,
|
wheres = append(wheres,
|
||||||
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND users.certified = true AND users.status='active')",
|
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND certified = true AND status='active')",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exclude private users' photos.
|
// Exclude private users' photos.
|
||||||
wheres = append(wheres,
|
wheres = append(wheres,
|
||||||
"NOT EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND photos.visibility = 'private')",
|
"NOT EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND visibility = 'private')",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Admin view: get ALL PHOTOS on the site, period.
|
// Admin view: get ALL PHOTOS on the site, period.
|
||||||
|
@ -827,14 +648,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("photos.visibility = ?", filterVisibility)
|
query = query.Where("visibility = ?", filterVisibility)
|
||||||
}
|
}
|
||||||
if filterExplicit != "" {
|
if filterExplicit != "" {
|
||||||
query = query.Where("photos.explicit = ?", filterExplicit == "true")
|
query = query.Where("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 users.certified IS NOT true AND users.status='active')",
|
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND certified IS NOT true AND status='active')",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -845,32 +666,12 @@ 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,7 +1,6 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -126,43 +125,6 @@ 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{}
|
||||||
|
|
|
@ -1,105 +0,0 @@
|
||||||
package models
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
/*
|
|
||||||
UsageStatistic holds basic analytics points for things like daily/monthly active user counts.
|
|
||||||
|
|
||||||
Generally, there will be one UserStatistic row for each combination of a UserID and Type for
|
|
||||||
each calendar day of the year. Type names may be like "dau" to log daily logins (Daily Active User),
|
|
||||||
or "chat" to log daily chat room users.
|
|
||||||
|
|
||||||
If a user logs in multiple times in the same day, their existing UsageStatistic for that day
|
|
||||||
is reused and the Counter is incremented. So if a user joins chat 3 times on the same day, there
|
|
||||||
will be a single row for that date for that user, but with a Counter of 3 in that case.
|
|
||||||
|
|
||||||
This makes it easier to query for aggregate reports on daily/monthly active users since each
|
|
||||||
row/event type combo only appears once per user per day.
|
|
||||||
*/
|
|
||||||
type UsageStatistic struct {
|
|
||||||
ID uint64 `gorm:"primaryKey"`
|
|
||||||
UserID uint64 `gorm:"uniqueIndex:idx_usage_statistics"`
|
|
||||||
Type string `gorm:"uniqueIndex:idx_usage_statistics"`
|
|
||||||
Date string `gorm:"uniqueIndex:idx_usage_statistics"` // unique days, yyyy-mm-dd format.
|
|
||||||
Counter uint64
|
|
||||||
CreatedAt time.Time `gorm:"index"` // full timestamps
|
|
||||||
UpdatedAt time.Time `gorm:"index"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Options for UsageStatistic Type values.
|
|
||||||
const (
|
|
||||||
UsageStatisticDailyVisit = "dau" // daily active user counter
|
|
||||||
UsageStatisticChatEntry = "chat" // daily chat room users
|
|
||||||
UsageStatisticForumUser = "forum" // daily forum users (when they open a thread)
|
|
||||||
UsageStatisticGalleryUser = "gallery" // daily Site Gallery user (when viewing the site gallery)
|
|
||||||
)
|
|
||||||
|
|
||||||
// LogDailyActiveUser will ping a UserStatistic for the current user to mark them present for the day.
|
|
||||||
func LogDailyActiveUser(user *User) error {
|
|
||||||
var (
|
|
||||||
date = time.Now().Format(time.DateOnly)
|
|
||||||
_, err = IncrementUsageStatistic(user, UsageStatisticDailyVisit, date)
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogDailyChatUser will ping a UserStatistic for the current user to mark them as having used the chat room today.
|
|
||||||
func LogDailyChatUser(user *User) error {
|
|
||||||
var (
|
|
||||||
date = time.Now().Format(time.DateOnly)
|
|
||||||
_, err = IncrementUsageStatistic(user, UsageStatisticChatEntry, date)
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogDailyForumUser will ping a UserStatistic for the current user to mark them as having used the forums today.
|
|
||||||
func LogDailyForumUser(user *User) error {
|
|
||||||
var (
|
|
||||||
date = time.Now().Format(time.DateOnly)
|
|
||||||
_, err = IncrementUsageStatistic(user, UsageStatisticForumUser, date)
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogDailyGalleryUser will ping a UserStatistic for the current user to mark them as having used the site gallery today.
|
|
||||||
func LogDailyGalleryUser(user *User) error {
|
|
||||||
var (
|
|
||||||
date = time.Now().Format(time.DateOnly)
|
|
||||||
_, err = IncrementUsageStatistic(user, UsageStatisticGalleryUser, date)
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUsageStatistic looks up a user statistic.
|
|
||||||
func GetUsageStatistic(user *User, statType, date string) (*UsageStatistic, error) {
|
|
||||||
var (
|
|
||||||
result = &UsageStatistic{}
|
|
||||||
res = DB.Model(&UsageStatistic{}).Where(
|
|
||||||
"user_id = ? AND type = ? AND date = ?",
|
|
||||||
user.ID, statType, date,
|
|
||||||
).First(&result)
|
|
||||||
)
|
|
||||||
return result, res.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// IncrementUsageStatistic finds or creates a UserStatistic type and increments the counter.
|
|
||||||
func IncrementUsageStatistic(user *User, statType, date string) (*UsageStatistic, error) {
|
|
||||||
user.muStatistic.Lock()
|
|
||||||
defer user.muStatistic.Unlock()
|
|
||||||
|
|
||||||
// Is there an existing row?
|
|
||||||
stat, err := GetUsageStatistic(user, statType, date)
|
|
||||||
if err != nil {
|
|
||||||
stat = &UsageStatistic{
|
|
||||||
UserID: user.ID,
|
|
||||||
Type: statType,
|
|
||||||
Counter: 0,
|
|
||||||
Date: date,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update and save it.
|
|
||||||
stat.Counter++
|
|
||||||
err = DB.Save(stat).Error
|
|
||||||
return stat, err
|
|
||||||
}
|
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
@ -46,9 +45,6 @@ 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
|
||||||
|
@ -230,22 +226,6 @@ 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.
|
||||||
|
@ -291,8 +271,7 @@ func (u *User) CanBeSeenBy(viewer *User) error {
|
||||||
|
|
||||||
// UserSearch config.
|
// UserSearch config.
|
||||||
type UserSearch struct {
|
type UserSearch struct {
|
||||||
Username string // fuzzy search by name or username
|
Username string
|
||||||
InUsername []string // exact set of usernames (e.g. On Chat)
|
|
||||||
Gender string
|
Gender string
|
||||||
Orientation string
|
Orientation string
|
||||||
MaritalStatus string
|
MaritalStatus string
|
||||||
|
@ -374,11 +353,6 @@ 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 (
|
||||||
|
@ -616,33 +590,6 @@ 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]
|
||||||
|
@ -707,6 +654,28 @@ func (u *User) NameOrUsername() string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VisibleAvatarURL returns a URL to the user's avatar taking into account
|
||||||
|
// their relationship with the current user. For example, if the avatar is
|
||||||
|
// friends-only and the current user can't see it, returns the path to the
|
||||||
|
// yellow placeholder avatar instead.
|
||||||
|
//
|
||||||
|
// Expects that UserRelationships are available on the user.
|
||||||
|
func (u *User) VisibleAvatarURL(currentUser *User) string {
|
||||||
|
canSee, visibility := u.CanSeeProfilePicture(currentUser)
|
||||||
|
if canSee {
|
||||||
|
return config.PhotoWebPath + "/" + u.ProfilePhoto.CroppedFilename
|
||||||
|
}
|
||||||
|
|
||||||
|
switch visibility {
|
||||||
|
case PhotoPrivate:
|
||||||
|
return "/static/img/shy-private.png"
|
||||||
|
case PhotoFriends:
|
||||||
|
return "/static/img/shy-friends.png"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/static/img/shy.png"
|
||||||
|
}
|
||||||
|
|
||||||
// CanSeeProfilePicture returns whether the current user can see the user's profile picture.
|
// 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.
|
||||||
|
@ -791,15 +760,6 @@ func (u *User) SetProfileField(name, value string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteProfileField removes a stored profile field.
|
|
||||||
func (u *User) DeleteProfileField(name string) error {
|
|
||||||
res := DB.Exec(
|
|
||||||
"DELETE FROM profile_fields WHERE user_id=? AND name=?",
|
|
||||||
u.ID, name,
|
|
||||||
)
|
|
||||||
return res.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProfileField returns the value of a profile field or blank string.
|
// 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,8 +42,6 @@ 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
|
||||||
|
|
|
@ -1,118 +0,0 @@
|
||||||
package photo
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/encryption"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/utility"
|
|
||||||
"github.com/golang-jwt/jwt/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
// VisibleAvatarURL returns the visible URL image to a user's square profile picture, from the point of view of the currentUser.
|
|
||||||
func VisibleAvatarURL(user, currentUser *models.User) string {
|
|
||||||
canSee, visibility := user.CanSeeProfilePicture(currentUser)
|
|
||||||
if canSee {
|
|
||||||
return SignedPublicAvatarURL(user.ProfilePhoto.CroppedFilename)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch visibility {
|
|
||||||
case models.PhotoPrivate:
|
|
||||||
return "/static/img/shy-private.png"
|
|
||||||
case models.PhotoFriends:
|
|
||||||
return "/static/img/shy-friends.png"
|
|
||||||
}
|
|
||||||
|
|
||||||
return "/static/img/shy.png"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReSignPhotoLinks will search a blob of text for photo gallery links ("/static/photos/*") and re-sign
|
|
||||||
// their JWT security tokens.
|
|
||||||
func ReSignPhotoLinks(currentUser *models.User, text string) string {
|
|
||||||
var matches = config.PhotoURLRegexp.FindAllStringSubmatch(text, -1)
|
|
||||||
for _, m := range matches {
|
|
||||||
var (
|
|
||||||
origString = m[0]
|
|
||||||
url = m[1]
|
|
||||||
filename string
|
|
||||||
)
|
|
||||||
log.Error("ReSignPhotoLinks: got [%s] url [%s]", origString, url)
|
|
||||||
|
|
||||||
// Trim the /static/photos/ prefix off to get the URL down to its base filename.
|
|
||||||
filename = strings.Split(url, "?")[0]
|
|
||||||
filename = strings.TrimPrefix(filename, config.PhotoWebPath)
|
|
||||||
filename = strings.TrimPrefix(filename, "/")
|
|
||||||
|
|
||||||
// Sign the URL and replace the original.
|
|
||||||
signed := SignedPhotoURL(currentUser, filename)
|
|
||||||
text = strings.ReplaceAll(text, origString, signed)
|
|
||||||
}
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
// SignedPhotoURL returns a URL path to a photo's filename, signed for the current user only.
|
|
||||||
func SignedPhotoURL(user *models.User, filename string) string {
|
|
||||||
return createSignedPhotoURL(user.ID, user.Username, filename, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SignedPublicAvatarURL returns a signed URL for a user's public square avatar image, which has
|
|
||||||
// a much more generous JWT expiration lifetime on it.
|
|
||||||
//
|
|
||||||
// The primary use case is for the chat room: users are sent into chat with their avatar URL,
|
|
||||||
// and it must be viewable to all users for a long time.
|
|
||||||
func SignedPublicAvatarURL(filename string) string {
|
|
||||||
return createSignedPhotoURL(0, "@", filename, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SignedPhotoClaims are a JWT claims object used to sign and authenticate image (direct .jpg) links.
|
|
||||||
type SignedPhotoClaims struct {
|
|
||||||
FilenameHash string `json:"f"` // Short hash of the Filename being signed.
|
|
||||||
Anyone bool `json:"a,omitempty"` // Non-authenticated signature (e.g. public sq avatar URLs)
|
|
||||||
|
|
||||||
// Standard claims. Notes:
|
|
||||||
// .Subject = username
|
|
||||||
jwt.RegisteredClaims
|
|
||||||
}
|
|
||||||
|
|
||||||
// FilenameHash returns a 'short' hash of the filename, for encoding in the SignedPhotoClaims.
|
|
||||||
//
|
|
||||||
// The hash is a truncated SHA256 hash as a basic validation measure against one JWT token being
|
|
||||||
// used to reveal an unrelated picture.
|
|
||||||
func FilenameHash(filename string) string {
|
|
||||||
return encryption.Hash([]byte(filename))[:6]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Common function to create a signed photo URL with an expiration.
|
|
||||||
func createSignedPhotoURL(userID uint64, username string, filename string, anyone bool) string {
|
|
||||||
|
|
||||||
// Claims expire on the 10th of next month.
|
|
||||||
var (
|
|
||||||
expiresAt = utility.NextMonth(time.Now(), 10)
|
|
||||||
claims = SignedPhotoClaims{
|
|
||||||
FilenameHash: FilenameHash(filename),
|
|
||||||
Anyone: anyone,
|
|
||||||
RegisteredClaims: encryption.StandardClaims(userID, username, expiresAt),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Lock the date stamps for a consistent JWT value for caching.
|
|
||||||
claims.IssuedAt = nil
|
|
||||||
claims.NotBefore = nil
|
|
||||||
|
|
||||||
log.Debug("createSignedPhotoURL(%s): %+v", filename, claims)
|
|
||||||
|
|
||||||
token, err := encryption.SignClaims(claims, []byte(config.Current.SignedPhoto.JWTSecret))
|
|
||||||
if err != nil {
|
|
||||||
log.Error("PhotoURL: SignClaims: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// JWT query string to append?
|
|
||||||
if token != "" {
|
|
||||||
token = "?jwt=" + token
|
|
||||||
}
|
|
||||||
|
|
||||||
return URLPath(filename) + token
|
|
||||||
}
|
|
|
@ -34,7 +34,6 @@ 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")())
|
||||||
|
@ -63,7 +62,6 @@ 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()))
|
||||||
|
@ -113,7 +111,6 @@ 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)
|
||||||
|
@ -121,7 +118,6 @@ 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,7 +172,8 @@ 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.
|
||||||
return u.PingLastLoginAt()
|
u.LastLoginAt = time.Now()
|
||||||
|
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,8 +38,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
||||||
"ToMarkdown": ToMarkdown,
|
"ToMarkdown": ToMarkdown,
|
||||||
"ToJSON": ToJSON,
|
"ToJSON": ToJSON,
|
||||||
"ToHTML": ToHTML,
|
"ToHTML": ToHTML,
|
||||||
"PhotoURL": PhotoURL(r),
|
"PhotoURL": photo.URLPath,
|
||||||
"VisibleAvatarURL": photo.VisibleAvatarURL,
|
|
||||||
"Now": time.Now,
|
"Now": time.Now,
|
||||||
"RunTime": RunTime,
|
"RunTime": RunTime,
|
||||||
"PrettyTitle": func() template.HTML {
|
"PrettyTitle": func() template.HTML {
|
||||||
|
@ -71,14 +70,6 @@ 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
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -107,19 +98,6 @@ 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 {
|
||||||
|
|
|
@ -6,17 +6,6 @@ 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?
|
||||||
|
@ -31,13 +20,21 @@ 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", FormatFloatToPrecision(thousands, 1))
|
return fmt.Sprintf("%sK", formatFloat(thousands))
|
||||||
}
|
}
|
||||||
|
|
||||||
if millions < 1000 {
|
if millions < 1000 {
|
||||||
return fmt.Sprintf("%sM", FormatFloatToPrecision(millions, 1))
|
return fmt.Sprintf("%sM", formatFloat(millions))
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%sB", FormatFloatToPrecision(billions, 1))
|
return fmt.Sprintf("%sB", formatFloat(billions))
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,22 +7,6 @@ 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,53 +7,6 @@ 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,14 +30,6 @@
|
||||||
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 {
|
||||||
|
@ -45,11 +37,6 @@
|
||||||
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?nocache=2") screen and (prefers-color-scheme: dark);
|
@import url("dark-theme.css") screen and (prefers-color-scheme: dark);
|
|
@ -1,11 +1,5 @@
|
||||||
/* 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;
|
||||||
}
|
}
|
||||||
|
@ -22,10 +16,6 @@ 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;
|
||||||
|
@ -106,18 +96,12 @@ img {
|
||||||
|
|
||||||
/* Mobile: notification badge near the hamburger menu */
|
/* Mobile: notification badge near the hamburger menu */
|
||||||
.nonshy-mobile-notification {
|
.nonshy-mobile-notification {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
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;
|
||||||
|
@ -165,12 +149,6 @@ nav.navbar {
|
||||||
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.
|
||||||
*/
|
*/
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 93 KiB |
|
@ -8,20 +8,13 @@ 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 += atMention + "\n\n";
|
$message.value += "@" + replyTo + "\n\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare the quoted message.
|
// Prepare the quoted message.
|
||||||
|
@ -37,18 +30,11 @@ 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 += atMention + "\n\n";
|
$message.value += "@" + replyTo + "\n\n";
|
||||||
$message.scrollIntoView();
|
$message.scrollIntoView();
|
||||||
$message.focus();
|
$message.focus();
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,13 +4,8 @@
|
||||||
<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">
|
<h1 class="title">User Dashboard</h1>
|
||||||
<span class="icon mr-4 pl-3">
|
<h2 class="subtitle">to your account</h2>
|
||||||
<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>
|
||||||
|
@ -187,7 +182,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">Quick Links</p>
|
<p class="card-header-title has-text-light">My Account</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
|
@ -208,7 +203,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 Photo
|
Upload Photos
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
@ -219,20 +214,14 @@
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="/settings">
|
<a href="/settings">
|
||||||
<span class="icon"><i class="fa fa-gear"></i></span>
|
<span class="icon"><i class="fa fa-edit"></i></span>
|
||||||
Edit Profile & Settings
|
Edit Profile & Settings
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<a href="/notes/me">
|
|
||||||
<span class="icon"><i class="fa fa-pen-to-square mr-1"></i></span>
|
|
||||||
My User Notes
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<a href="/photo/certification">
|
<a href="/photo/certification">
|
||||||
<span class="icon"><i class="fa fa-certificate"></i></span>
|
<span class="icon"><i class="fa fa-certificate"></i></span>
|
||||||
My Certification Photo
|
Certification Photo
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
@ -241,6 +230,12 @@
|
||||||
Blocked Users
|
Blocked Users
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/notes/me">
|
||||||
|
<span class="icon"><i class="fa fa-pen-to-square mr-1"></i></span>
|
||||||
|
My User Notes
|
||||||
|
</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>
|
||||||
|
@ -255,6 +250,12 @@
|
||||||
</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>
|
||||||
|
@ -486,7 +487,9 @@
|
||||||
<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">
|
||||||
|
@ -597,11 +600,6 @@
|
||||||
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}}
|
||||||
|
@ -630,18 +628,6 @@
|
||||||
<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,18 +2,16 @@
|
||||||
{{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}} {{if not .IsExternalView}}user-theme-hero{{end}}">
|
<section class="hero {{if and .LoggedIn (not .IsPrivate)}}is-info{{else}}is-light is-bold{{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="{{VisibleAvatarURL .User nil}}" data-photo-id="{{.User.ProfilePhoto.ID}}">
|
<img src="{{.User.VisibleAvatarURL nil}}" data-photo-id="{{.User.ProfilePhoto.ID}}">
|
||||||
{{else}}
|
{{else}}
|
||||||
<a href="/u/{{.User.Username}}/photos">
|
<img src="{{.User.VisibleAvatarURL .CurrentUser}}" data-photo-id="{{.User.ProfilePhoto.ID}}">
|
||||||
<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 -->
|
||||||
|
@ -313,76 +311,43 @@
|
||||||
|
|
||||||
<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 user-theme-card-title">
|
<header class="card-header has-background-link">
|
||||||
<div class="card-header-title">
|
<p class="card-header-title has-text-light">
|
||||||
<div class="columns is-mobile is-gapless nonshy-fullwidth">
|
|
||||||
<div class="column">
|
|
||||||
About Me
|
About Me
|
||||||
</div>
|
</p>
|
||||||
{{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 user-theme-card-body">
|
<div class="card-content">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{or (ReSignPhotoLinks (ToMarkdown (.User.GetProfileField "about_me"))) "n/a"}}
|
{{or (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 user-theme-card-title">
|
<header class="card-header has-background-link">
|
||||||
<div class="card-header-title">
|
<p class="card-header-title has-text-light">
|
||||||
<div class="columns is-mobile is-gapless nonshy-fullwidth">
|
|
||||||
<div class="column">
|
|
||||||
My Interests
|
My Interests
|
||||||
</div>
|
</p>
|
||||||
{{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 user-theme-card-body">
|
<div class="card-content">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{or (ReSignPhotoLinks (ToMarkdown (.User.GetProfileField "interests"))) "n/a"}}
|
{{or (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 user-theme-card-title">
|
<header class="card-header has-background-link">
|
||||||
<div class="card-header-title">
|
<p class="card-header-title has-text-light">
|
||||||
<div class="columns is-mobile is-gapless nonshy-fullwidth">
|
|
||||||
<div class="column">
|
|
||||||
Music/Bands/Movies
|
Music/Bands/Movies
|
||||||
</div>
|
</p>
|
||||||
{{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 user-theme-card-body">
|
<div class="card-content">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{{or (ReSignPhotoLinks (ToMarkdown (.User.GetProfileField "music_movies"))) "n/a"}}
|
{{or (ToMarkdown (.User.GetProfileField "music_movies")) "n/a"}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -390,14 +355,14 @@
|
||||||
|
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div class="card block">
|
<div class="card block">
|
||||||
<header class="card-header has-background-info user-theme-card-title">
|
<header class="card-header has-background-info">
|
||||||
<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 user-theme-card-body">
|
<div class="card-content">
|
||||||
<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">
|
||||||
|
@ -476,14 +441,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card block">
|
<div class="card block">
|
||||||
<header class="card-header has-background-info user-theme-card-title">
|
<header class="card-header has-background-info">
|
||||||
<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 user-theme-card-body">
|
<div class="card-content">
|
||||||
|
|
||||||
<!-- 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">
|
||||||
|
@ -527,17 +492,6 @@
|
||||||
</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,15 +82,6 @@
|
||||||
<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 -->
|
||||||
|
@ -109,17 +100,11 @@
|
||||||
|
|
||||||
<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. <strong>This website is actively monitored</strong> to keep on top of this stuff,
|
members who violate this rule will be banned. This website is actively monitored 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}}
|
||||||
|
|
||||||
|
@ -193,7 +178,7 @@
|
||||||
|
|
||||||
<div class="column pl-1">
|
<div class="column pl-1">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="wcs">Location:</label>
|
<label class="label" for="wcs">Location: <span class="tag is-success">New!</span></label>
|
||||||
<input type="text" class="input"
|
<input type="text" class="input"
|
||||||
name="wcs" id="wcs"
|
name="wcs" id="wcs"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
@ -310,14 +295,6 @@
|
||||||
{{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>
|
||||||
|
|
||||||
|
@ -453,7 +430,6 @@
|
||||||
{{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}}
|
||||||
|
@ -494,7 +470,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" id="profile/about_me">
|
<div class="field">
|
||||||
<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" id="profile/interests">
|
<div class="field">
|
||||||
<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" id="profile/music_movies">
|
<div class="field">
|
||||||
<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"
|
||||||
|
@ -835,7 +835,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-3">
|
<label class="checkbox mt-2">
|
||||||
<input type="radio"
|
<input type="radio"
|
||||||
name="visibility"
|
name="visibility"
|
||||||
value="external"
|
value="external"
|
||||||
|
@ -850,7 +850,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-3">
|
<label class="checkbox mt-2">
|
||||||
<input type="radio"
|
<input type="radio"
|
||||||
name="visibility"
|
name="visibility"
|
||||||
value="private"
|
value="private"
|
||||||
|
@ -871,14 +871,15 @@
|
||||||
<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">
|
<div class="has-text-info ml-4">
|
||||||
<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 mt-3">
|
<label class="checkbox">
|
||||||
<input type="radio"
|
<input type="radio"
|
||||||
name="dm_privacy"
|
name="dm_privacy"
|
||||||
value=""
|
value=""
|
||||||
|
@ -890,26 +891,24 @@
|
||||||
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 mt-3">
|
<label class="checkbox">
|
||||||
<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 mt-3">
|
<label class="checkbox">
|
||||||
<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
|
||||||
|
@ -920,77 +919,6 @@
|
||||||
|
|
||||||
<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
|
||||||
|
@ -1493,10 +1421,9 @@ 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(/\.$/, '');
|
||||||
let tabName = name.split('/')[0]; // "#profile/about_me"
|
if (!name) name = "profile";
|
||||||
if (!tabName) name = "profile";
|
|
||||||
$activeTab.style.display = 'none';
|
$activeTab.style.display = 'none';
|
||||||
switch (tabName) {
|
switch (name) {
|
||||||
case "look":
|
case "look":
|
||||||
$activeTab = $look;
|
$activeTab = $look;
|
||||||
break;
|
break;
|
||||||
|
@ -1559,9 +1486,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.
|
||||||
|
@ -1636,7 +1563,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" || typeof(PushNotificationSubscribe) === "undefined") {
|
if (typeof(window.Notification) === "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,38 +187,11 @@
|
||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
{{if .FeedbackPager.Total}}
|
{{if .FeedbackPager.Total}}
|
||||||
<div class="block">
|
<span>
|
||||||
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}}).
|
||||||
</div>
|
</span>
|
||||||
{{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>
|
||||||
|
@ -251,29 +224,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="/admin/feedback?id={{.ID}}&visit=true"
|
<a href="{{$Root.Request.URL.Path}}?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="/admin/feedback?id={{.ID}}&visit=true"
|
<a href="{{$Root.Request.URL.Path}}?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="/admin/feedback?id={{.ID}}&visit=true&profile=true"
|
<a href="{{$Root.Request.URL.Path}}?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="/admin/feedback?id={{.ID}}&visit=true"
|
<a href="{{$Root.Request.URL.Path}}?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="/admin/feedback?id={{.ID}}&visit=true" class="fa fa-external-link ml-2" target="_blank"></a>
|
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true" class="fa fa-external-link ml-2" target="_blank"></a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -177,11 +177,9 @@
|
||||||
<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>
|
||||||
|
@ -213,8 +211,7 @@
|
||||||
{{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>
|
||||||
|
@ -248,7 +245,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.replaceAll("\\n", "\n");
|
textarea.value = elem.value;
|
||||||
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}}?{{QueryPlus "intent" ""}}">All</a>
|
<a href="{{.Request.URL.Path}}?acknowledged={{.Acknowledged}}">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}}?{{QueryPlus "intent" "contact"}}">Contact</a>
|
<a href="{{.Request.URL.Path}}?acknowledged={{.Acknowledged}}&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}}?{{QueryPlus "intent" "report"}}">Reports</a>
|
<a href="{{.Request.URL.Path}}?acknowledged={{.Acknowledged}}&intent=report">Reports</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -38,93 +38,16 @@
|
||||||
<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}}?{{QueryPlus "acknowledged" "false"}}">Unread</a>
|
<a href="{{.Request.URL.Path}}?intent={{.Intent}}">Unread</a>
|
||||||
</li>
|
</li>
|
||||||
<li{{if .Acknowledged}} class="is-active"{{end}}>
|
<li{{if .Acknowledged}} class="is-active"{{end}}>
|
||||||
<a href="{{.Request.URL.Path}}?{{QueryPlus "acknowledged" "true"}}">Acknowledged</a>
|
<a href="{{.Request.URL.Path}}?acknowledged=true&intent={{.Intent}}">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">
|
||||||
|
@ -228,24 +151,6 @@
|
||||||
{{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,8 +12,6 @@
|
||||||
</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">
|
||||||
|
@ -24,9 +22,6 @@
|
||||||
{{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
|
||||||
|
@ -104,23 +99,6 @@
|
||||||
|
|
||||||
<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>
|
||||||
|
@ -140,9 +118,6 @@
|
||||||
{{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}}
|
||||||
|
@ -166,9 +141,6 @@
|
||||||
{{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}}
|
||||||
|
@ -176,45 +148,6 @@
|
||||||
</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,6 +1,7 @@
|
||||||
{{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">
|
||||||
|
@ -9,20 +10,21 @@
|
||||||
<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 -}}
|
{{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 class="has-navbar-fixed-top">
|
<body>
|
||||||
<nav class="navbar is-fixed-top" role="navigation" aria-label="main navigation">
|
<nav class="navbar" 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 }}
|
||||||
|
@ -379,10 +381,7 @@
|
||||||
<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">
|
||||||
<span class="is-size-3">🎂 Happy birthday!</span>
|
<h2>🎂 Happy birthday!</h2>
|
||||||
|
|
||||||
<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,27 +48,11 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .IsShyUser}}
|
{{if .IsShyUser}}
|
||||||
<div class="notification is-warning is-light content">
|
<div class="notification is-danger is-light">
|
||||||
<p>
|
<i class="fa fa-exclamation-triangle"></i> You have a <strong>Shy Account</strong> and you may not enter
|
||||||
<i class="fa fa-exclamation-triangle"></i> You have a <strong>Shy Account</strong>, so you will experience
|
the chat room at this time, where our {{PrettyTitle}} members may be sharing their cameras. You are
|
||||||
limited functionality on the chat room:
|
sharing no public photos with the community, so you get limited access to ours.
|
||||||
</p>
|
<a href="/faq#shy-faqs">Learn more about how to resolve this issue. <small class="fa fa-external-link"></small></a>
|
||||||
|
|
||||||
<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}}
|
||||||
|
|
||||||
|
@ -106,11 +90,6 @@
|
||||||
<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>
|
||||||
|
|
|
@ -1,245 +0,0 @@
|
||||||
{{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 denied</h1>
|
<h1>Your certification photo has been rejected</h1>
|
||||||
|
|
||||||
<p>Dear {{.Data.Username}},</p>
|
<p>Dear {{.Data.Username}},</p>
|
||||||
|
|
||||||
|
|
|
@ -4,33 +4,10 @@
|
||||||
<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,12 +701,6 @@
|
||||||
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>
|
||||||
|
@ -1137,8 +1131,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 MAY join the chat room, but have certain restrictions applied (such
|
all their pictures hidden are not permitted in the chat room at this time - but they may get
|
||||||
as an inability to broadcast or watch any webcam, or share or see any picture shared on chat).
|
their own separate room later where they can bother other similarly shy members there.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
@ -1406,9 +1400,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 <strong>can</strong> join the <i class="fa fa-message"></i> <strong>Chat Room</strong>, however
|
You can not join the <i class="fa fa-message"></i> <strong>Chat Room</strong>. You guys
|
||||||
some features will be restricted to Shy Accounts: you will not be able to broadcast OR watch any webcam
|
may soon get your own chat room, though. Many of us {{PrettyTitle}} nudists would not
|
||||||
on chat, nor can you share OR view any photo shared by others on the chat room.
|
enjoy our webcams being watched by blank profiles.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
@ -1419,13 +1413,6 @@
|
||||||
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>
|
||||||
|
@ -1434,10 +1421,6 @@
|
||||||
</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,16 +140,18 @@
|
||||||
</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>
|
Permit Photos <i class="fa fa-camera ml-1"></i> <i class="fa fa-peace has-text-danger ml-1"></i>
|
||||||
</label>
|
</label>
|
||||||
<p class="help">
|
<p class="help">
|
||||||
Check this box if the forum allows photos to be uploaded.
|
Check this box if the forum allows photos to be uploaded (not implemented)
|
||||||
</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,13 +203,7 @@
|
||||||
</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,29 +140,16 @@
|
||||||
</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}}">
|
<a href="/u/{{$c.User.Username}}">{{$c.User.NameOrUsername}}</a>
|
||||||
{{- 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-2">
|
<div class="is-size-7 mt-1">
|
||||||
<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>
|
||||||
|
@ -319,14 +306,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}}" data-comment-id="{{.ID}}">
|
class="has-text-dark nonshy-quote-button" data-quote-body="{{.Message}}" data-reply-to="{{.User.Username}}">
|
||||||
<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}}" data-comment-id="{{.ID}}">
|
class="has-text-dark nonshy-reply-button" data-reply-to="{{.User.Username}}">
|
||||||
<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,7 +69,18 @@
|
||||||
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>
|
||||||
|
|
||||||
|
@ -140,22 +151,15 @@
|
||||||
|
|
||||||
<!-- 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"
|
<form action="/messages/delete" method="POST" class="is-inline" onsubmit="return confirm('Delete this message?')">
|
||||||
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 is-outline has-text-grey is-small ml-4">
|
<button class="button has-text-danger is-outline is-small p-1 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,34 +41,29 @@
|
||||||
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>{{.Demographic.LastUpdated.Format "January _2, 2006"}}</strong> here is a brief
|
and the "hyper sexual porn sites" out there. As of <strong>July 31, 2024</strong> here is a brief peek inside the
|
||||||
<a href="/insights">peek inside the website</a> to see what the balance of content is like in our community.
|
website to see what the balance of content is like in our community.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
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}}).
|
Photo Gallery: only 24% of our photos are 'explicit' or sexual in nature (6,750 out of 27,331).
|
||||||
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: {{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.
|
<li>Nudists vs. Exhibitionists: 3,209 (71%, out of 4,462) of members have opted-in to see explicit content on the site.
|
||||||
Only {{.Demographic.People.PercentExplicitPhoto}}% of members ({{FormatNumberCommas .Demographic.People.ExplicitPhoto}}) have shared at least one 'explicit' photo on their gallery.</li>
|
Only 45% of members (2,022) 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>
|
||||||
<a href="/insights">Click here to see detailed insights</a> about the people and content in our community -- updated regularly!
|
(Coming soon: a 'live statistics' page which will give up-to-date information and pretty graphs & charts;
|
||||||
|
in the mean time these were manually gathered).
|
||||||
</small>
|
</small>
|
||||||
</p>
|
</p>
|
||||||
{{end}}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -24,84 +24,97 @@ 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">
|
||||||
/* Hero banner */
|
{{template "profile-theme-hero-style" .}}
|
||||||
.user-theme-hero {
|
header.card-header {
|
||||||
background-image: linear-gradient(141deg, {{$heroA}}, {{$heroB}}) !important;
|
|
||||||
|
|
||||||
.title, .subtitle {
|
|
||||||
color: {{if eq (.GetProfileField "hero-text-dark") "true"}}#4a4a4a{{else}}#f5f5f5{{end}} !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card Title colors */
|
|
||||||
.user-theme-card-title {
|
|
||||||
background-color: {{$cardTitleBG}} !important;
|
background-color: {{$cardTitleBG}} !important;
|
||||||
|
}
|
||||||
* {
|
p.card-header-title {
|
||||||
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"}}
|
||||||
.user-theme-card-body {
|
div.box, .container div.card-content, table.table, table.table strong, td {
|
||||||
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 m !important;
|
color: #4a4a4a;
|
||||||
|
}
|
||||||
|
div.tag {
|
||||||
|
background-color: #ccc;
|
||||||
|
color: #4a4a4a;
|
||||||
|
}
|
||||||
|
strong {
|
||||||
|
color: #4a4a4a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table {
|
/* More text color overrides (h1's etc. look light on prefers-dark color schemes) */
|
||||||
background-color: inherit;
|
.container div.card-content .content * {
|
||||||
|
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"}}
|
||||||
.user-theme-card-body {
|
div.box, .container div.card-content, table.table, table.table strong, td {
|
||||||
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 m !important;
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
div.tag {
|
||||||
|
background-color: #333;
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
strong {
|
||||||
|
color: #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table {
|
/* More text color overrides (h1's etc. look dark on prefers-light color schemes) */
|
||||||
background-color: inherit;
|
.container div.card-content .content * {
|
||||||
|
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}}
|
||||||
|
|
||||||
|
|
|
@ -1,169 +0,0 @@
|
||||||
{{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,12 +17,6 @@
|
||||||
{{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}}
|
||||||
|
@ -73,11 +67,6 @@
|
||||||
|
|
||||||
<!-- 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>
|
||||||
|
@ -425,9 +414,6 @@
|
||||||
{{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>
|
||||||
|
@ -471,21 +457,18 @@
|
||||||
</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>
|
||||||
</div>
|
{{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}}
|
{{end}}
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .AreWeGrantedPrivate}}
|
{{if .AreWeGrantedPrivate}}
|
||||||
|
@ -498,9 +481,6 @@
|
||||||
|
|
||||||
{{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}}
|
||||||
|
@ -553,8 +533,6 @@
|
||||||
<!-- 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
|
||||||
|
@ -562,8 +540,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}}" data-photo-id="{{.ID}}" target="_blank"
|
<a href="/photo/view?id={{.ID}}" data-url="{{PhotoURL .Filename}}" target="_blank"
|
||||||
class="js-modal-trigger">
|
class="js-modal-trigger" data-target="detail-modal">
|
||||||
<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}}>
|
||||||
|
@ -579,7 +557,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) (not .HasAdminLabelNonExplicit)}}
|
{{if and (not .Explicit) (ne .UserID $Root.CurrentUser.ID)}}
|
||||||
<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"
|
||||||
|
@ -680,8 +658,6 @@
|
||||||
<!-- 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
|
||||||
|
@ -689,8 +665,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}}" data-photo-id="{{.ID}}" target="_blank"
|
<a href="/photo/view?id={{.ID}}" data-url="{{PhotoURL .Filename}}" target="_blank"
|
||||||
class="js-modal-trigger">
|
class="js-modal-trigger" data-target="detail-modal">
|
||||||
<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}}>
|
||||||
|
@ -705,7 +681,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) (not .HasAdminLabelNonExplicit)}}
|
{{if and (not .Explicit) (ne .UserID $Root.CurrentUser.ID)}}
|
||||||
<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"
|
||||||
|
@ -764,45 +740,6 @@
|
||||||
|
|
||||||
{{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">
|
||||||
|
@ -816,65 +753,9 @@
|
||||||
</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"),
|
||||||
|
@ -898,60 +779,14 @@
|
||||||
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>
|
||||||
|
|
|
@ -100,27 +100,11 @@
|
||||||
|
|
||||||
<!-- Timestamp -->
|
<!-- Timestamp -->
|
||||||
<div>
|
<div>
|
||||||
<span class="tag is-grey is-light has-text-dark mr-2">
|
|
||||||
<span class="icon">
|
|
||||||
<i class="fa fa-eye"></i>
|
|
||||||
</span>
|
|
||||||
<span>{{.Photo.Views}} view{{PluralizeU64 .Photo.Views}}</span>
|
|
||||||
</span>
|
|
||||||
<small class="has-text-grey">Uploaded {{.Photo.CreatedAt.Format "Jan _2 2006 15:04:05"}}</small>
|
<small class="has-text-grey">Uploaded {{.Photo.CreatedAt.Format "Jan _2 2006 15:04:05"}}</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Show admin labels to admins -->
|
|
||||||
{{if .CurrentUser.HasAdminScope "social.moderator.photo"}}
|
|
||||||
{{if .Photo.AdminLabel}}
|
|
||||||
<div class="mt-2">
|
|
||||||
<i class="fa fa-peace has-text-danger mr-1"></i>
|
|
||||||
Admin Label: {{.Photo.AdminLabel}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<!-- Quick mark photo as explicit -->
|
<!-- Quick mark photo as explicit -->
|
||||||
{{if and (not .Photo.Explicit) (ne .Photo.UserID .CurrentUser.ID) (not .Photo.HasAdminLabelNonExplicit)}}
|
{{if and (not .Photo.Explicit) (ne .Photo.UserID .CurrentUser.ID)}}
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<a href="#"
|
<a href="#"
|
||||||
class="has-text-danger is-size-7 nonshy-mark-explicit"
|
class="has-text-danger is-size-7 nonshy-mark-explicit"
|
||||||
|
@ -178,7 +162,7 @@
|
||||||
|
|
||||||
<!-- Report button except on your own pic -->
|
<!-- Report button except on your own pic -->
|
||||||
{{if not .IsOwnPhoto}}
|
{{if not .IsOwnPhoto}}
|
||||||
<div class="column is-narrow mb-1">
|
<div class="column is-narrow ml-2">
|
||||||
<a href="/contact?intent=report&subject=report.photo&id={{.Photo.ID}}" class="button is-small has-text-danger">
|
<a href="/contact?intent=report&subject=report.photo&id={{.Photo.ID}}" class="button is-small has-text-danger">
|
||||||
<span class="icon"><i class="fa fa-flag"></i></span>
|
<span class="icon"><i class="fa fa-flag"></i></span>
|
||||||
<span>Report</span>
|
<span>Report</span>
|
||||||
|
@ -196,7 +180,7 @@
|
||||||
<span>Change Log</span>
|
<span>Change Log</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -280,25 +264,12 @@
|
||||||
<div class="box has-background-link-light has-text-dark" id="p{{.ID}}">
|
<div class="box has-background-link-light has-text-dark" id="p{{.ID}}">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-2 has-text-centered">
|
<div class="column is-2 has-text-centered">
|
||||||
<!-- User has no display name distinct from their username? -->
|
|
||||||
{{ $NoDisplayName := eq .User.NameOrUsername .User.Username }}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<a href="/u/{{.User.Username}}">
|
<a href="/u/{{.User.Username}}">
|
||||||
{{template "avatar-96x96" .User}}
|
{{template "avatar-96x96" .User}}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<a href="/u/{{.User.Username}}">
|
<a href="/u/{{.User.Username}}">{{.User.NameOrUsername}}</a>
|
||||||
{{- if $NoDisplayName}}<small class="is-size-7">@</small>{{end -}}
|
|
||||||
{{.User.NameOrUsername}}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Username if the display name wasn't identical -->
|
|
||||||
{{if not $NoDisplayName}}
|
|
||||||
<div class="is-size-7">
|
|
||||||
@{{.User.Username}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column content">
|
<div class="column content">
|
||||||
|
@ -354,14 +325,14 @@
|
||||||
|
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<a href="#"
|
<a href="#"
|
||||||
class="has-text-dark nonshy-quote-button" data-quote-body="{{.Message}}" data-reply-to="{{.User.Username}}" data-comment-id="{{.ID}}">
|
class="has-text-dark nonshy-quote-button" data-quote-body="{{.Message}}" data-reply-to="{{.User.Username}}">
|
||||||
<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="#"
|
<a href="#"
|
||||||
class="has-text-dark nonshy-reply-button" data-reply-to="{{.User.Username}}" data-comment-id="{{.ID}}">
|
class="has-text-dark nonshy-reply-button" data-reply-to="{{.User.Username}}">
|
||||||
<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>
|
||||||
|
|
|
@ -50,21 +50,6 @@
|
||||||
(page {{.Pager.Page}} of {{.Pager.Pages}}).
|
(page {{.Pager.Page}} of {{.Pager.Pages}}).
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- On the Shared With Me page, let the user know about the new privacy option. -->
|
|
||||||
{{if .IsGrantee}}
|
|
||||||
<div class="block has-text-smaller">
|
|
||||||
<strong class="has-text-success">
|
|
||||||
<i class="fa fa-info-circle"></i>
|
|
||||||
Pro Tip:
|
|
||||||
</strong>
|
|
||||||
|
|
||||||
If you receive a lot of unsolicited private photo shares from people and you wish you could do something
|
|
||||||
about that, check out the <a href="/settings#privacy">Privacy Settings</a> for an option to limit who is allowed
|
|
||||||
to share their private gallery with you. For example, you can limit it to Friends only or to people who
|
|
||||||
<em>you</em> had previously sent a DM to.
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if not .IsGrantee}}
|
{{if not .IsGrantee}}
|
||||||
<div class="columns is-gapless is-centered">
|
<div class="columns is-gapless is-centered">
|
||||||
<div class="column is-narrow mx-1 my-2">
|
<div class="column is-narrow mx-1 my-2">
|
||||||
|
@ -101,66 +86,27 @@
|
||||||
<div class="media block">
|
<div class="media block">
|
||||||
<div class="media-left">
|
<div class="media-left">
|
||||||
{{template "avatar-64x64" .}}
|
{{template "avatar-64x64" .}}
|
||||||
|
|
||||||
<!-- Friendship badge -->
|
|
||||||
{{if $Root.FriendMap.Get .ID}}
|
|
||||||
<div>
|
|
||||||
<span class="is-size-7 has-text-warning">
|
|
||||||
<i class="fa fa-user-group" title="Friends"></i>
|
|
||||||
Friends
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<!-- Liked badge -->
|
|
||||||
{{$LikedStats := $Root.LikedMap.Get .ID}}
|
|
||||||
{{if $LikedStats.UserLikes}}
|
|
||||||
<div>
|
|
||||||
<span class="is-size-7">
|
|
||||||
<i class="fa fa-heart has-text-danger" title="Friends"></i>
|
|
||||||
Liked
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="media-content">
|
<div class="media-content">
|
||||||
<p class="title is-4">
|
<p class="title is-4">
|
||||||
<a href="/u/{{.Username}}" class="has-text-dark">
|
<a href="/u/{{.Username}}" class="has-text-dark">{{.NameOrUsername}}</a>
|
||||||
{{.NameOrUsername}}
|
|
||||||
</a>
|
|
||||||
{{if eq .Visibility "private"}}
|
|
||||||
<sup class="fa fa-mask is-size-7" title="Private Profile"></sup>
|
|
||||||
{{end}}
|
|
||||||
</p>
|
</p>
|
||||||
<p class="subtitle is-6 mb-1">
|
<p class="subtitle is-6 mb-1">
|
||||||
<span class="icon"><i class="fa fa-user"></i></span>
|
<span class="icon"><i class="fa fa-user"></i></span>
|
||||||
<a href="/u/{{.Username}}">{{.Username}}</a>
|
<a href="/u/{{.Username}}">{{.Username}}</a>
|
||||||
|
|
||||||
<!-- Not Certified or Shy Account badge -->
|
|
||||||
{{if not .Certified}}
|
{{if not .Certified}}
|
||||||
<span class="has-text-danger is-size-7">
|
<span class="has-text-danger">
|
||||||
<i class="fa fa-certificate"></i>
|
<span class="icon"><i class="fa fa-certificate"></i></span>
|
||||||
<span>Not Certified!</span>
|
<span>Not Certified!</span>
|
||||||
</span>
|
</span>
|
||||||
{{else if $Root.ShyMap.Get .ID}}
|
|
||||||
<span class="has-text-danger is-size-7">
|
|
||||||
<i class="fa fa-ghost"></i>
|
|
||||||
<span>Shy Account</span>
|
|
||||||
</span>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .IsAdmin}}
|
{{if .IsAdmin}}
|
||||||
<span class="tag is-danger is-light p-1" style="font-size: x-small">
|
<span class="has-text-danger">
|
||||||
<i class="fa fa-peace mr-1"></i>
|
<span class="icon"><i class="fa fa-peace"></i></span>
|
||||||
<span>Admin</span>
|
<span>Admin</span>
|
||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- Photo count pulled to the right -->
|
|
||||||
<a href="/u/{{.Username}}/photos?visibility=private" class="tag is-private is-light is-pulled-right">
|
|
||||||
<i class="fa fa-camera mr-2"></i>
|
|
||||||
{{$Root.PhotoCountMap.Get .ID}}
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Indicator if they are sharing back -->
|
<!-- Indicator if they are sharing back -->
|
||||||
|
@ -198,17 +144,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{if not $Root.IsGrantee}}
|
||||||
<!-- Card Footers -->
|
|
||||||
{{if $Root.IsGrantee}}
|
|
||||||
<footer class="card-footer">
|
|
||||||
<button type="submit" name="intent" value="decline" class="card-footer-item button is-danger is-outlined"
|
|
||||||
onclick="return confirm('Do you want to decline access to this person\'s private photos? Doing so will remove them from your Shared With Me list and you will no longer see their private photos unless they share with you again in the future.')">
|
|
||||||
<span class="icon"><i class="fa fa-thumbs-down"></i></span>
|
|
||||||
<span>Decline</span>
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
{{else}}
|
|
||||||
<footer class="card-footer">
|
<footer class="card-footer">
|
||||||
<button type="submit" name="intent" value="revoke" class="card-footer-item button is-danger is-outlined"
|
<button type="submit" name="intent" value="revoke" class="card-footer-item button is-danger is-outlined"
|
||||||
onclick="return confirm('Are you sure you want to revoke private photo access to this user?')">
|
onclick="return confirm('Are you sure you want to revoke private photo access to this user?')">
|
||||||
|
|
|
@ -17,7 +17,6 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{ $Root := . }}
|
|
||||||
{{ $User := .CurrentUser }}
|
{{ $User := .CurrentUser }}
|
||||||
|
|
||||||
<!-- Drag/Drop Modal -->
|
<!-- Drag/Drop Modal -->
|
||||||
|
@ -265,6 +264,7 @@
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<i class="fa fa-thumbtack mr-1 has-text-success"></i>
|
<i class="fa fa-thumbtack mr-1 has-text-success"></i>
|
||||||
Pinned Photo
|
Pinned Photo
|
||||||
|
<span class="tag is-success ml-2">New!</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
|
@ -391,34 +391,6 @@
|
||||||
<span>Explicit Content</span>
|
<span>Explicit Content</span>
|
||||||
<span class="icon"><i class="fa fa-fire"></i></span>
|
<span class="icon"><i class="fa fa-fire"></i></span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!-- Flagged Explicit photo: show a warning if this photo was recently flagged by the community. -->
|
|
||||||
{{$IsFlagged := and .EditPhoto .EditPhoto.Flagged .EditPhoto.Explicit}}
|
|
||||||
{{if $IsFlagged}}
|
|
||||||
<div class="notification is-danger is-light py-3 px-3 has-text-smaller content">
|
|
||||||
<p>
|
|
||||||
<strong>
|
|
||||||
<i class="fa fa-exclamation-triangle"></i>
|
|
||||||
Notice:
|
|
||||||
</strong>
|
|
||||||
This photo was classified by the community as containing 'Explicit' content.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Please review <a href="/tos#explicit-photos">what {{.Title}} considers an 'Explicit' photo</a>
|
|
||||||
and if this photo fits the description, <strong>please</strong> leave this photo with the
|
|
||||||
'Explicit' box checked, below.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
If you disagree that this photo should have been marked as 'Explicit,' you <strong>MAY</strong>
|
|
||||||
uncheck the box below and remove the Explicit status. <strong>Note:</strong> the website admin will
|
|
||||||
be notified to take a look as well if you do this, to verify that your photo has the correct 'Explicit'
|
|
||||||
setting.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if eq .Intent "profile_pic"}}
|
{{if eq .Intent "profile_pic"}}
|
||||||
<span class="has-text-danger">
|
<span class="has-text-danger">
|
||||||
Your default profile picture should
|
Your default profile picture should
|
||||||
|
@ -433,17 +405,12 @@
|
||||||
that to your page, just not as your default profile picture!
|
that to your page, just not as your default profile picture!
|
||||||
</p>
|
</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<label class="checkbox"
|
<label class="checkbox">
|
||||||
{{if $IsFlagged}}title="You MAY remove this check if you disagree that this photo should be marked Explicit"{{end}}
|
|
||||||
>
|
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="explicit"
|
name="explicit"
|
||||||
value="true"
|
value="true"
|
||||||
{{if .EditPhoto.Explicit}}checked{{end}}
|
{{if .EditPhoto.Explicit}}checked{{end}}>
|
||||||
{{if .EditPhoto.HasAdminLabelForceExplicit}}disabled{{end}}>
|
|
||||||
{{if $IsFlagged}}<del class="cursor-not-allowed">{{end}}
|
|
||||||
This photo contains explicit content
|
This photo contains explicit content
|
||||||
{{if $IsFlagged}}</del>{{end}}
|
|
||||||
</label>
|
</label>
|
||||||
<p class="help">
|
<p class="help">
|
||||||
Mark this box if this photo contains any explicit content, including an
|
Mark this box if this photo contains any explicit content, including an
|
||||||
|
@ -490,44 +457,6 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Admin Labels -->
|
|
||||||
{{if and .EditPhoto (.RequestUser.HasAdminScope "social.moderator.photo")}}
|
|
||||||
<hr>
|
|
||||||
<div class="field">
|
|
||||||
<label class="label has-text-danger">
|
|
||||||
<span>Admin Labels</span>
|
|
||||||
<span class="icon"><i class="fa fa-peace"></i></span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<p class="help">
|
|
||||||
The options below can apply moderation rules to this picture, especially regarding
|
|
||||||
its 'explicit' status. For example: if a community member flagged this picture as
|
|
||||||
explicit, but it does NOT need to be marked as such, select that label below: and
|
|
||||||
the website will no longer allow this photo to be flagged as explicit again.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{range .AvailableAdminLabels}}
|
|
||||||
<div class="field">
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox"
|
|
||||||
name="admin_label"
|
|
||||||
value="{{.Value}}"
|
|
||||||
{{if ($Root.EditPhoto.HasAdminLabel .Value)}}checked{{end}}>
|
|
||||||
{{.Label}}
|
|
||||||
</label>
|
|
||||||
<p class="help">
|
|
||||||
{{.Help}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<p class="help">
|
|
||||||
<strong>Reminder:</strong> click on 'Save Changes' to apply these labels!
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -380,20 +380,20 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
A photo is considered "explicit" if it depicts <strong>any</strong> of the following features:
|
A photo is considered "explicit" if it depicts <em>any</em> of the following features:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
A close-up view of genitalia or where the genitals are the central focus of the picture.
|
A close-up view of genitalia or where the genitals are the central focus of the picture.
|
||||||
</li>
|
</li>
|
||||||
<li>An erect or semi-erect penis if the subject has one, especially if they are grabbing it.</li>
|
<li>An erect penis if the subject has one, especially if they are grabbing it.</li>
|
||||||
<li>
|
<li>
|
||||||
"Spread eagle" pictures that clearly and especially show intimate body parts such
|
"Spread eagle" pictures that clearly and especially show intimate body parts such
|
||||||
as butt holes or vulvae.
|
as butt holes or vulvae.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
A depiction of any sexual activity, including but not limited to: masturbation, oral sex,
|
A depiction of a sexual act, including but not limited to: masturbation, oral sex,
|
||||||
anal or vaginal penetration, humping, or any content intended to sexually arouse the
|
anal or vaginal penetration, humping, or any content intended to sexually arouse the
|
||||||
viewer. If it can be reasonably considered to be "porn" it is an explicit photo.
|
viewer. If it can be reasonably considered to be "porn" it is an explicit photo.
|
||||||
</li>
|
</li>
|
||||||
|
@ -405,17 +405,6 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p>
|
|
||||||
As a general rule of thumb: if a picture could be reasonably considered to be "porn" then you should
|
|
||||||
mark it as Explicit when uploading it to your gallery.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<strong>Important:</strong> extreme and commonly offensive content (such as fisting, gaping or prolapsed
|
|
||||||
ass holes, etc.) are <strong>NOT</strong> permitted on {{PrettyTitle}}. Please review the following
|
|
||||||
section for a list of Prohibited Content.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3 id="prohibited-content">Prohibited Content <a href="#prohibited-content" class="fa fa-paragraph is-size-6"></a></h3>
|
<h3 id="prohibited-content">Prohibited Content <a href="#prohibited-content" class="fa fa-paragraph is-size-6"></a></h3>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
@ -425,7 +414,6 @@
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<strong>Illegal content:</strong>
|
|
||||||
You may NOT upload any content that is considered to be illegal in the United States or
|
You may NOT upload any content that is considered to be illegal in the United States or
|
||||||
in any of the 50 States therein. This includes, but is not limited to: bestiality (or
|
in any of the 50 States therein. This includes, but is not limited to: bestiality (or
|
||||||
sexual acts involving animals), child sexually abusive material (CSAM), ANY nude photo
|
sexual acts involving animals), child sexually abusive material (CSAM), ANY nude photo
|
||||||
|
@ -435,7 +423,6 @@
|
||||||
or other unlawful content.
|
or other unlawful content.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Extreme content:</strong>
|
|
||||||
You may NOT upload sexual material depicting extreme or commonly offensive content
|
You may NOT upload sexual material depicting extreme or commonly offensive content
|
||||||
including, but not limited to: watersports (peeing onto or into another person), scat
|
including, but not limited to: watersports (peeing onto or into another person), scat
|
||||||
(any depiction of obviously apparent fecal matter), prolapsed rectum, anal fisting,
|
(any depiction of obviously apparent fecal matter), prolapsed rectum, anal fisting,
|
||||||
|
|
Loading…
Reference in New Issue
Block a user