Compare commits

...

45 Commits

Author SHA1 Message Date
Noah Petherbridge
90b7eea0dc Fix Feedback Comments + Fixed Nav Anchor Workaround
* Admin Feedback page: fix the visit button to go to a reported comment.
* CSS: with the fixed top nav bar, anchor links (like to the FAQ or
  specific comments on a thread) were scrolling so the anchor was below
  the nav bar. Add a scroll-padding-top to work around this.
2024-11-23 16:45:39 -08:00
Noah Petherbridge
39398a1f78 Improve comment threads and reply syntax
* On Forums and photo comment threads: display the poster's username
  below their display name, if their username differs. If they do not
  have a distinct display name, a small @ appears in front of their
  display name instead.
* On Quote & Reply, wrap the @mention with a Markdown hyperlink to the
  specific comment ID.
2024-11-23 12:59:40 -08:00
Noah Petherbridge
c1cf5df70e Minor CSS fixes
* Breadcrumbs: override Bulma's nowrap style, as long forum thread
  titles could cause horizontal scrolling on mobile.
* Dashboard: fix double <a> tag wrapping for profile pictures.
* Member Search: fix invalid HTML code where div tags closed the
  wrapping p tag.
2024-11-23 12:38:29 -08:00
Noah Petherbridge
4b582b2141 Dark theme tweak 2024-11-22 22:26:53 -08:00
Noah Petherbridge
fcd2cbd615 Fixed top nav bar 2024-11-22 17:22:13 -08:00
Noah Petherbridge
63471e2e9b Show GIFs properly on add/edit forum post page 2024-11-12 20:10:40 -08:00
Noah Petherbridge
ad0eb6e17c Dark hero.is-light banner for dark theme 2024-10-19 16:07:52 -07:00
Noah Petherbridge
7ffcd6b3a8 Photo Views: Count a mouseover as a view too
For video elements (animated GIFs), since the 'click' for lightbox modal
doesn't work, mouseover and play/pause count as views. This can unfairly
lead videos to climb as the most viewed images while pictures need a
click or a 'like' to count.

So, count images as viewed on their mouseover event as well.
2024-10-19 15:44:50 -07:00
Noah Petherbridge
b52d9df958 Member Search: Add a filter for "Currently on chat" 2024-10-19 13:07:17 -07:00
Noah Petherbridge
1b3e8cb250 Private Photo Sharing Improvements
* Add a user privacy setting so they can gate who is allowed to share private
  photos with them (for people who dislike unsolicited shares):
  * Anybody (default)
  * Friends only
  * Friends + people whom they have sent a DM to (on the main website)
  * Nobody
* Add gating around whether to display the prompt to unlock your private photos
  while you are viewing somebody's gallery:
  * The current user needs at least one private photo to share.
  * The target user's new privacy preference is taken into consideration.
* The "should show private photo share prompt" logic is also used on the actual
  share page, e.g. for people who manually paste in a username to share with.
  You can not grant access to private photos which don't exist.
* Improve the UI on the private photo shares page.
  * Profile cards to add elements from the Member Directory page, such as a
    Friends and Liked indicator.
  * A count of the user's Private photos is shown, which links directly to
    their private gallery.
* Add "Decline" buttons to the Shared With Me page: so the target of a private
  photo share is able to remove/cancel shares with them.
2024-10-19 12:44:47 -07:00
Noah Petherbridge
e146c09850 Improvements to Feedback & Reports
* Add an AboutUserID field to feedbacks, so when the report is about a
  picture that is later deleted, the feedback can still link to the
  original owner's account instead of showing an error.
* Add filters to the User Notes page so the admin can see:
  * All feedback From or About the user or their content (default)
  * Feedback created by the user
  * Feedback about the user or their content
  * Fuzzy search for any feedback containing the user's name.
* On chat room reports: make the @channel ID a clickable user profile
  link for convenience.
2024-10-17 19:21:18 -07:00
Noah Petherbridge
704124157d User Themes refactor
Instead of fighting to override Bulma CSS classes, add user-theme-*
classes for simpler styling.
2024-10-13 20:31:09 -07:00
Noah Petherbridge
b7bee75e1f Function to re-sign photo URLs on profile pages
* With the new JWT signatures on photo URLs, it was no longer possible for
  creative users to embed their gallery photos on their profile page.
* Add a function to ReSignPhotoLinks that finds/replaces (on the server side)
  all references to paths under "/static/photos/" and gives them a fresh
  ?jwt= query string signature.
* Note: only applies to the profile page essays, ReSignPhotoLinks is a
  template func that must be opted-in on a per page basis.

Other miscellaneous fixes

* Add "Edit" buttons in the corners of profile cards, when the current user
  looks at their profile page. They link to URIs like
  "/settings#profile/about_me" which will now:
  1. Select the "Profile settings" tab like #profile
  2. Scroll and focus the profile essay field that the user clicked to edit.
2024-10-13 19:50:11 -07:00
Noah Petherbridge
cb37934935 Birthday chat room style fix
The h2 was showing in light text on dark mode making it unreadable.
2024-10-12 13:14:40 -07:00
Noah Petherbridge
26f9c4d71d Minor bugfix 2024-10-07 16:50:21 +00:00
Noah Petherbridge
2262edfe09 Improve browser caching with signed JWT photo URLs
* JWT tokens will now expire on the 10th of the next month, to produce
  consistent values for a period of time and aid with browser caching.
2024-10-05 20:24:45 -07:00
Noah Petherbridge
77a9d9a7fd Code cleanup 2024-10-04 21:22:52 -07:00
Noah Petherbridge
8078ff8755 Batch Edit/Delete Photos + Misc Fixes
Certification Required page:

* Show helpful advice if the reason for the page is only that the user had
  deleted their default profile pic, but their account was certified.

Batch Photo Delete & Visibility:

* On user galleries, owners and admins can batch Delete or Set Visibility on
  many photos at once. Checkboxes appear in the edit/delete row of each photo,
  and bulk actions appear at the bottom of the page along with select/unselect
  all boxes.
* Deprecated the old /photo/delete endpoint: it now redirects to the batch
  delete page with the one photo ID.

Misc Changes:

* Notifications now sort unread to the top always.
2024-10-04 21:17:20 -07:00
Noah Petherbridge
cbdabe791e Improve Signed Photo URLs
* The photo signing JWT tokens carry more fields to validate against:
  * The username the token is assigned to (or '@' for anyone)
  * An 'anyone' boolean for widely public images, such as for the chat room
    and public profile pages.
  * A short filename hash of the image in question (whether a Photo or a
    CommentPhoto) - so that the user can't borrow a JWT token from the chat
    room and reveal a different picture.
* Refactored where the VisibleAvatarURL function lives, to avoid a cyclic
  dependency error.
  * Originally: (*models.User).VisibleAvatarURL(other *models.User)
  * Now: (pkg/photo).VisibleAvatarURL(user, currentUser *models.User)
2024-10-03 20:14:34 -07:00
Noah Petherbridge
7869ff83ba Signed and Authenticated Static Photo URLs
* Add support for authenticated static photo URLs, leveraging the NGINX module
  ngx_http_auth_request. The README is updated with an example NGINX config
  how to set this up on the proxy side.
* In settings.json a new SignedPhoto section is added: not enabled by default.
* PhotoURL will append a ?jwt= token to the /static/photos/ path for the
  current user, which expires after 30 seconds.
* When SignedPhoto is enabled, it will enforce that the JWT token is valid and
  matches the username of the current logged-in user, or else will return with
  a 403 Forbidden error.
2024-10-03 18:04:14 -07:00
Noah Petherbridge
295183559d Admin labels on photos surrounding explicit flags
* Add 'admin labels' to photos so an admin can classify a photo as:
  * Not Explicit: e.g. it was flagged by the community but does not
    actually need to be explicit. This option will hide the prompt to
    report the explicit photo again.
  * Force Explicit: if a user is fighting an explicit flag and keeps
    removing it from their photo, the photo can be force marked
    explicit.
* Admin labels appear on the Permalink page and in the edit photo
  settings when viewed as a photo moderator admin.
2024-10-02 16:22:19 -07:00
Noah Petherbridge
542d0bb300 Improvements on community flagged explicit photos
When a user marks that another photo should have been marked as explicit:

* The owner of that photo gets a notification about it, which reminds them of
  the explicit photo policy.
* The photo's "Flagged" boolean is set (along with the Explicit boolean)
* The 'Edit' page on a Flagged photo shows a red banner above the Explicit
  option, explaining that it was flagged. The checkbox text is crossed-out,
  with a "no" cursor and title text over - but can still be unchecked.

If the user removes the Explicit flag on a flagged photo and saves it:

* An admin report is generated to notify to take a look too.
* The Explicit flag is cleared as normal
* The Flagged boolean is also cleared on this photo: if they set it back to
  Explicit again themselves, the red banner won't appear and it won't notify
  again - unless a community member flagged it again!

Also makes some improvements to the admin page:

* On photo reports: show a blurred-out (clickable to reveal) photo on feedback
  items about photos.
2024-10-01 20:44:11 -07:00
Noah Petherbridge
c8d9cdbb3a Demographics page: count only 'gallery' photos
The photo stats were counting ALL public photos of certified members,
whether featured on the Site Gallery or not. Update the query to filter
for Site Gallery photos instead.
2024-09-28 13:10:56 -07:00
Noah Petherbridge
106bcd377e User Forums: Enable PermitPhotos for all forum owners 2024-09-28 12:58:52 -07:00
Noah Petherbridge
f2e847922f Tweak admin permissions and photo view counts
* Profile pictures on profile pages now link to the gallery when clicked.
* Admins can no longer automatically see the default profile pic on profile
  pages unless they have photo moderator ability.
* Photo view counts are not added when an admin with photo moderator ability
  should not have otherwise been able to see the photo.
2024-09-28 12:45:20 -07:00
Noah Petherbridge
3fdae1d8d7 Various minor tweaks
* Demographics page:
    * Show percents with up to 1 decimal place of precision.
    * On tablets+ align the percent text to the right.
    * On photo counts, only include certified active user photos.
    * On gender/orientation demographics, pad the remaining "No answer" counts
      with the set of users who have no profile_fields set in the database yet.
* Admin certification page:
    * Add additional "common rejection reasons"
    * Add a confirm prompt when viewing the Rejected list to avoid accidental
      approval of previously rejected cert photos.
2024-09-27 17:37:45 -07:00
Noah Petherbridge
0c7fc7e866 Improve message reporting format 2024-09-26 21:05:27 -07:00
Noah Petherbridge
ab880148ad 'Likes' view an image + Tweak inbox page
* Hitting the Like button on a photo will mark it as viewed.
* Move the 'Report' button on the message inbox page, to instead be in
  the footer of each DM.
* Improve message reporting behavior to include the content of the
  message in the admin report.
2024-09-26 20:56:16 -07:00
Noah Petherbridge
7aa1d512fc Photo view count tweaks
* The owner of a photo no longer counts any views on it.
* Add event handlers to mark animated GIFs viewed on the gallery page:
  if the user mouse overs or pauses the video.
2024-09-26 20:32:04 -07:00
Noah Petherbridge
9d6c299fdd Photo View Counters 2024-09-25 22:46:33 -07:00
Noah Petherbridge
955ace1e91 Optimize sorting gallery by Likes/Comments via caching 2024-09-21 17:25:36 -07:00
Noah Petherbridge
0cd72a96ed Optimize gallery sort by likes or comments 2024-09-21 16:59:37 -07:00
Noah Petherbridge
944b2e28e9 Sort Gallery photos by Likes and Comments 2024-09-21 16:39:18 -07:00
Noah Petherbridge
9575041d1e Bugfix when removing all chat moderation rules 2024-09-20 20:32:56 -07:00
Noah Petherbridge
066765d2dc Chat Moderation Rules + Shy Accounts on Chat
* Add chat moderation rules to the website, so admins can apply selective rules
  to problematic users. Available rules are:
  * redcam: user's camera is always NSFW.
  * nobroadcast: user can not broadcast their camera.
  * novideo: user can not broadcast OR watch any video.
  * noimage: user can not share OR see any shared image on chat.
* The page to manage a user's active rules is available on their admin card of
  their profile page. When the user has rules active, a yellow counter is shown
  by the link to manage their rules.
  * Only chat moderator admins have access to the page or can see the yellow
    counter to know whether rules are active.
* "Shy Accounts" are now permitted on the chat room! With some moderation rules
  automatically applied to them: novideo,noimage.
* Update the Shy Account FAQ and messaging on the chat landing page.
* Update the auto-kick from chat behavior regarding shy accounts:
  * They are kicked from chat only when an update to their profile settings will
    transition then FROM a non-shy into a shy account.
  * For example: when saving their profile settings (going private) or when
    editing or deleting a photo (if they will have no more public photos left)
2024-09-19 19:30:02 -07:00
Noah Petherbridge
ae84ddf449 Web Push Notifications: Disable script when impersonated
If an admin needs to impersonate a regular user (to diagnose a support
issue or investigate a reported conversation thread), the web push
script is disabled so that the admin doesn't get subscribed to push
notifications for that user.
2024-09-14 12:07:18 -07:00
Noah Petherbridge
7991320256 Fix SQL queries on demographics page 2024-09-12 10:42:07 -07:00
Noah Petherbridge
02487ba2f4 Adjust progressbar styles 2024-09-11 19:37:10 -07:00
Noah Petherbridge
4b43071f28 Bugfix 2024-09-11 19:35:45 -07:00
Noah Petherbridge
2f31d678d0 Usage Statistics and Website Demographics
Adds two new features to collect and show useful analytics.

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

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

Misc cleanup:
* Rearrange model list in data export to match the auto-create statements.
* In data exports, include the forum_memberships, push_notifications and
  usage_statistics tables.
2024-09-11 19:28:52 -07:00
Noah Petherbridge
8d9588b039 Notification when admin users are blocked 2024-09-10 15:43:34 -07:00
Noah Petherbridge
79ea384d40 More adjusting email sending behavior 2024-09-09 20:59:46 -07:00
Noah Petherbridge
463253dbb5 Email delivery tweaks 2024-09-09 20:52:53 -07:00
Noah Petherbridge
276eddfd8e Search and filter admin feedback & reports 2024-09-07 14:50:11 -07:00
Noah Petherbridge
2c7532434a My List: show owned forums only when not official forums 2024-08-30 18:49:12 -07:00
97 changed files with 4234 additions and 728 deletions

104
README.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -79,6 +79,7 @@ var (
"dm_privacy",
"blur_explicit",
"site_gallery_default", // default view on site gallery (friends-only or all certified?)
"chat_moderation_rules",
}
// Choices for the Contact Us subject
@ -119,6 +120,30 @@ var (
regexp.MustCompile(`\b(telegram|whats\s*app|signal|kik|session)\b`),
regexp.MustCompile(`https?://(t.me|join.skype.com|zoom.us|whereby.com|meet.jit.si|wa.me)`),
}
// Chat Moderation Rules.
ChatModerationRules = []ChecklistOption{
{
Value: "redcam",
Label: "Red camera",
Help: "The user's camera is forced to 'explicit' when they are broadcasting.",
},
{
Value: "nobroadcast",
Label: "No broadcast",
Help: "The user can not broadcast their webcam, but may still watch other peoples' webcams.",
},
{
Value: "novideo",
Label: "No webcam privileges ('Shy Accounts')",
Help: "The user can not broadcast or watch any webcam. Note: this option supercedes all other video-related rules.",
},
{
Value: "noimage",
Label: "No image sharing privileges ('Shy Accounts')",
Help: "The user can not share or see any image shared on chat.",
},
}
)
// ContactUs choices for the subject drop-down.
@ -133,6 +158,13 @@ type Option struct {
Label string
}
// ChecklistOption for checkbox-lists.
type ChecklistOption struct {
Value string
Label string
Help string
}
// NotificationOptout field values (stored in user ProfileField table)
const (
NotificationOptOutFriendPhotos = "notif_optout_friends_photos"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -62,6 +62,10 @@ func Settings() http.HandlerFunc {
// Are we POSTing?
if r.Method == http.MethodPost {
// Will they BECOME a Shy Account with this change?
var wasShy = user.IsShy()
intent := r.PostFormValue("intent")
switch intent {
case "profile":
@ -220,6 +224,7 @@ func Settings() http.HandlerFunc {
var (
visibility = models.UserVisibility(r.PostFormValue("visibility"))
dmPrivacy = r.PostFormValue("dm_privacy")
ppPrivacy = r.PostFormValue("private_photo_gate")
)
user.Visibility = models.UserVisibilityPublic
@ -232,6 +237,7 @@ func Settings() http.HandlerFunc {
// Set profile field prefs.
user.SetProfileField("dm_privacy", dmPrivacy)
user.SetProfileField("private_photo_gate", ppPrivacy)
if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err)
@ -472,8 +478,10 @@ func Settings() http.HandlerFunc {
}
// Maybe kick them from the chat room if they had become a Shy Account.
if _, err := chat.MaybeDisconnectUser(user); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
if !wasShy && user.IsShy() {
if _, err := chat.MaybeDisconnectUser(user); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
}
}
templates.Redirect(w, r.URL.Path+hashtag+".")

View File

@ -139,24 +139,29 @@ func Signup() http.HandlerFunc {
}
// Already an account?
if _, err := models.FindUser(email); err == nil {
if user, err := models.FindUser(email); err == nil {
// We don't want to admit that the email already is registered, so send an email to the
// address in case the user legitimately forgot, but flash the regular success message.
if err := mail.LockSending("signup", email, config.SignupTokenExpires); err == nil {
err := mail.Send(mail.Message{
To: email,
Subject: "You already have a nonshy account",
Template: "email/already_signed_up.html",
Data: map[string]interface{}{
"Title": config.Title,
"URL": config.Current.BaseURL + "/forgot-password",
},
})
if err != nil {
session.FlashError(w, r, "Error sending an email: %s", err)
}
if user.IsBanned() {
log.Error("Do not send signup e-mail to %s: user is banned", email)
} else {
log.Error("LockSending: signup e-mail is not sent to %s: one was sent recently", email)
if err := mail.LockSending("signup", email, config.EmailDebounceDefault); err == nil {
err := mail.Send(mail.Message{
To: email,
Subject: "You already have a nonshy account",
Template: "email/already_signed_up.html",
Data: map[string]interface{}{
"Title": config.Title,
"URL": config.Current.BaseURL + "/forgot-password",
},
})
if err != nil {
session.FlashError(w, r, "Error sending an email: %s", err)
}
} else {
log.Error("LockSending: signup e-mail is not sent to %s: one was sent recently", email)
}
}
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -109,7 +109,14 @@ func NewPost() http.HandlerFunc {
if len(quoteCommentID) > 0 {
if i, err := strconv.Atoi(quoteCommentID); err == nil {
if comment, err := models.GetComment(uint64(i)); err == nil {
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),
)
}
}
}

View File

@ -97,6 +97,13 @@ func Thread() http.HandlerFunc {
// Is the current user subscribed to notifications on this thread?
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
// Ping this user as having used the forums today.
go func() {
if err := models.LogDailyForumUser(currentUser); err != nil {
log.Error("LogDailyForumUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
}
}()
var vars = map[string]interface{}{
"Forum": forum,
"Thread": thread,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -71,6 +71,9 @@ func Edit() http.HandlerFunc {
// Are we saving the changes?
if r.Method == http.MethodPost {
// Record if this change is going to make them a Shy Account.
var wasShy = currentUser.IsShy()
var (
caption = strings.TrimSpace(r.FormValue("caption"))
altText = strings.TrimSpace(r.FormValue("alt_text"))
@ -85,6 +88,9 @@ func Edit() http.HandlerFunc {
// Are we GOING private?
goingPrivate = visibility == models.PhotoPrivate && visibility != photo.Visibility
// Is the user fighting an 'Explicit' tag added by the community?
isFightingExplicitFlag = photo.Flagged && photo.Explicit && !isExplicit
)
if len(altText) > config.AltTextMaxLength {
@ -105,6 +111,24 @@ func Edit() http.HandlerFunc {
models.NewFieldDiff("Visibility", photo.Visibility, visibility),
}
// Admin label options.
if requestUser.HasAdminScope(config.ScopePhotoModerator) {
var adminLabel string
if labels, ok := r.PostForm["admin_label"]; ok {
adminLabel = strings.Join(labels, ",")
}
diffs = append(diffs,
models.NewFieldDiff("Admin Label", photo.AdminLabel, adminLabel),
)
photo.AdminLabel = adminLabel
}
// Admin label: forced explicit?
if photo.HasAdminLabelForceExplicit() {
isExplicit = true
}
photo.Caption = caption
photo.AltText = altText
photo.Explicit = isExplicit
@ -138,6 +162,34 @@ func Edit() http.HandlerFunc {
setProfilePic = false
}
// If the user is fighting a recent Explicit flag from the community.
if isFightingExplicitFlag {
// Notify the admin (unless we are an admin).
if !requestUser.IsAdmin {
fb := &models.Feedback{
Intent: "report",
Subject: "Explicit photo flag dispute",
UserID: currentUser.ID,
TableName: "photos",
TableID: photo.ID,
Message: "A user's photo was recently **flagged by the community** as Explicit, and its owner " +
"has **removed** the Explicit setting.\n\n" +
"Please check out the photo below and verify what its Explicit setting should be:",
}
if err := models.CreateFeedback(fb); err != nil {
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
}
}
// Allow this change but clear the Flagged status.
photo.Flagged = false
// Clear the notification about this.
models.RemoveSpecificNotification(currentUser.ID, models.NotificationExplicitPhoto, "photos", photo.ID)
}
if err := photo.Save(); err != nil {
session.FlashError(w, r, "Couldn't save photo: %s", err)
}
@ -158,8 +210,11 @@ func Edit() http.HandlerFunc {
models.LogUpdated(currentUser, requestUser, "photos", photo.ID, "Updated the photo's settings.", diffs)
// Maybe kick them from the chat if this photo save makes them a Shy Account.
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
currentUser.FlushCaches()
if !wasShy && currentUser.IsShy() {
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
}
}
// If this picture has moved to Private, revoke any notification we gave about it before.
@ -177,6 +232,10 @@ func Edit() http.HandlerFunc {
"EditPhoto": photo,
"SiteGalleryThrottled": SiteGalleryThrottled,
"SiteGalleryThrottleLimit": config.SiteGalleryRateLimitMax,
// Available admin labels enum.
"RequestUser": requestUser,
"AvailableAdminLabels": config.AdminLabelPhotoOptions,
}
if err := tmpl.Execute(w, r, vars); err != nil {
@ -187,121 +246,10 @@ func Edit() http.HandlerFunc {
}
// Delete controller (/photo/Delete?id=N) to change properties about your picture.
//
// DEPRECATED: send them to the batch-edit endpoint.
func Delete() http.HandlerFunc {
// Reuse the upload page but with an EditPhoto variable.
tmpl := templates.Must("photo/delete.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 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
}
templates.Redirect(w, fmt.Sprintf("/photo/batch-edit?intent=delete&id=%s", r.FormValue("id")))
})
}

View File

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

View File

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

View File

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

View File

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

71
pkg/encryption/jwt.go Normal file
View File

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

View File

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

View File

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

View File

@ -199,6 +199,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
reverse = []*Block{} // Users who block the target
userIDs = []uint64{user.ID}
usernames = map[uint64]string{}
admins = map[uint64]bool{}
)
// Get the complete blocklist and bucket them into forward and reverse.
@ -218,6 +219,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
type scanItem struct {
ID uint64
Username string
IsAdmin bool
}
var scan = []scanItem{}
if res := DB.Table(
@ -225,6 +227,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
).Select(
"id",
"username",
"is_admin",
).Where(
"id IN ?", userIDs,
).Scan(&scan); res.Error != nil {
@ -233,6 +236,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
for _, row := range scan {
usernames[row.ID] = row.Username
admins[row.ID] = row.IsAdmin
}
}
@ -245,6 +249,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
if username, ok := usernames[row.TargetUserID]; ok {
result.Blocks = append(result.Blocks, BlocklistInsightUser{
Username: username,
IsAdmin: admins[row.TargetUserID],
Date: row.CreatedAt,
})
}
@ -253,6 +258,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
if username, ok := usernames[row.SourceUserID]; ok {
result.BlockedBy = append(result.BlockedBy, BlocklistInsightUser{
Username: username,
IsAdmin: admins[row.SourceUserID],
Date: row.CreatedAt,
})
}
@ -268,6 +274,7 @@ type BlocklistInsight struct {
type BlocklistInsightUser struct {
Username string
IsAdmin bool
Date time.Time
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

118
pkg/photo/photosign.go Normal file
View File

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

View File

@ -34,6 +34,7 @@ func New() http.Handler {
mux.HandleFunc("GET /sw.js", index.ServiceWorker())
mux.HandleFunc("GET /about", index.StaticTemplate("about.html")())
mux.HandleFunc("GET /features", index.StaticTemplate("features.html")())
mux.HandleFunc("GET /insights", index.Demographics())
mux.HandleFunc("GET /faq", index.StaticTemplate("faq.html")())
mux.HandleFunc("GET /tos", index.StaticTemplate("tos.html")())
mux.HandleFunc("GET /privacy", index.StaticTemplate("privacy.html")())
@ -62,6 +63,7 @@ func New() http.Handler {
mux.Handle("GET /photo/view", middleware.LoginRequired(photo.View()))
mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit()))
mux.Handle("/photo/delete", middleware.LoginRequired(photo.Delete()))
mux.Handle("/photo/batch-edit", middleware.LoginRequired(photo.BatchEdit()))
mux.Handle("/photo/certification", middleware.LoginRequired(photo.Certification()))
mux.Handle("GET /photo/private", middleware.LoginRequired(photo.Private()))
mux.Handle("/photo/private/share", middleware.LoginRequired(photo.Share()))
@ -111,6 +113,7 @@ func New() http.Handler {
// JSON API endpoints.
mux.HandleFunc("GET /v1/version", api.Version())
mux.HandleFunc("GET /v1/auth/static", api.PhotoSignAuth())
mux.HandleFunc("GET /v1/users/me", api.LoginOK())
mux.HandleFunc("POST /v1/users/check-username", api.UsernameCheck())
mux.HandleFunc("GET /v1/web-push/vapid-public-key", webpush.VAPIDPublicKey)
@ -118,6 +121,7 @@ func New() http.Handler {
mux.Handle("GET /v1/web-push/unregister", middleware.LoginRequired(webpush.UnregisterAll()))
mux.Handle("POST /v1/likes", middleware.LoginRequired(api.Likes()))
mux.Handle("GET /v1/likes/users", middleware.LoginRequired(api.WhoLikes()))
mux.Handle("POST /v1/photo/{photo_id}/view", middleware.LoginRequired(api.ViewPhoto()))
mux.Handle("POST /v1/notifications/read", middleware.LoginRequired(api.ReadNotification()))
mux.Handle("POST /v1/notifications/delete", middleware.LoginRequired(api.ClearNotification()))
mux.Handle("POST /v1/photos/mark-explicit", middleware.LoginRequired(api.MarkPhotoExplicit()))

View File

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

View File

@ -38,7 +38,8 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
"ToMarkdown": ToMarkdown,
"ToJSON": ToJSON,
"ToHTML": ToHTML,
"PhotoURL": photo.URLPath,
"PhotoURL": PhotoURL(r),
"VisibleAvatarURL": photo.VisibleAvatarURL,
"Now": time.Now,
"RunTime": RunTime,
"PrettyTitle": func() template.HTML {
@ -70,6 +71,14 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
// Get a description for an admin scope (e.g. for transparency page).
"AdminScopeDescription": config.AdminScopeDescription,
// "ReSignPhotoLinks": photo.ReSignPhotoLinks,
"ReSignPhotoLinks": func(s template.HTML) template.HTML {
if currentUser, err := session.CurrentUser(r); err == nil {
return template.HTML(photo.ReSignPhotoLinks(currentUser, string(s)))
}
return s
},
}
}
@ -98,6 +107,19 @@ func RunTime(r *http.Request) string {
return "ERROR"
}
// PhotoURL returns a URL path to photos.
func PhotoURL(r *http.Request) func(filename string) string {
return func(filename string) string {
// Get the current user to sign a JWT token.
var token string
if currentUser, err := session.CurrentUser(r); err == nil {
return photo.SignedPhotoURL(currentUser, filename)
}
return photo.URLPath(filename) + token
}
}
// BlurExplicit returns true if the current user has the blur_explicit setting on and the given Photo is Explicit.
func BlurExplicit(r *http.Request) func(*models.Photo) bool {
return func(photo *models.Photo) bool {

View File

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

View File

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

View File

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

View File

@ -30,6 +30,14 @@
color: rgb(26, 0, 5) !important;
}
/* hero.is-light.is-bold still is white on Bulma's dark theme */
.hero.is-light.is-bold {
background-image: linear-gradient(141deg, #333 0, #181818 100%);
* {
color: #fff;
}
}
/* force lit-up notification buttons (on the mobile top nav, e.g. new Messages/Friends)
to show as a bright bulma is-warning style (.tag.is-warning) */
.nonshy-navbar-notification {
@ -37,6 +45,11 @@
color: rgb(26, 0, 5) !important;
}
/* glassy background for fixed nav bar when you scroll other elements under it */
nav.navbar {
background-color: rgba(20, 22, 26, .75) !important;
backdrop-filter: blur(5px);
}
.has-text-dark {
/* note: this css file otherwise didn't override this, dark's always dark, brighten it! */

View File

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

View File

@ -1,5 +1,11 @@
/* Custom CSS styles */
html {
/* With the fixed top nav bar, this works around anchor-links to make them appear
correctly BELOW the nav bar instead of overlapped underneath it. */
scroll-padding-top: 52px;
}
abbr {
cursor: help;
}
@ -16,6 +22,10 @@ abbr {
cursor: default;
}
.has-text-smaller {
font-size: smaller;
}
img {
/* https://stackoverflow.com/questions/12906789/preventing-an-image-from-being-draggable-or-selectable-without-using-js */
user-drag: none;
@ -96,12 +106,18 @@ img {
/* Mobile: notification badge near the hamburger menu */
.nonshy-mobile-notification {
position: absolute;
position: fixed;
top: 10px;
right: 50px;
z-index: 1000;
}
/* glassy background for fixed nav bar when you scroll other elements under it */
nav.navbar {
background-color: rgba(255, 255, 255, .75);
backdrop-filter: blur(5px);
}
/* PWA: loading indicator in the corner of the page */
#nonshy-pwa-loader {
display: none;
@ -149,6 +165,12 @@ img {
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.
*/

BIN
web/static/img/shybot.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@ -8,13 +8,20 @@ document.addEventListener('DOMContentLoaded', function() {
// at the page header instead of going to the dedicated comment page.
(document.querySelectorAll(".nonshy-quote-button") || []).forEach(node => {
const message = node.dataset.quoteBody,
replyTo = node.dataset.replyTo;
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) => {
e.preventDefault();
if (replyTo) {
$message.value += "@" + replyTo + "\n\n";
$message.value += atMention + "\n\n";
}
// Prepare the quoted message.
@ -30,11 +37,18 @@ document.addEventListener('DOMContentLoaded', function() {
});
(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) => {
e.preventDefault();
$message.value += "@" + replyTo + "\n\n";
$message.value += atMention + "\n\n";
$message.scrollIntoView();
$message.focus();
});

View File

@ -4,8 +4,13 @@
<section class="hero is-link is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">User Dashboard</h1>
<h2 class="subtitle">to your account</h2>
<h1 class="title">
<span class="icon mr-4 pl-3">
<i class="fa fa-house-user"></i>
</span>
<span>My Dashboard</span>
</h1>
<h2 class="subtitle">Settings &amp; Notifications</h2>
</div>
</div>
</section>
@ -182,7 +187,7 @@
<div class="card block">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">My Account</p>
<p class="card-header-title has-text-light">Quick Links</p>
</header>
<div class="card-content">
@ -203,7 +208,7 @@
<li>
<a href="/photo/upload">
<span class="icon"><i class="fa fa-upload"></i></span>
Upload Photos
Upload Photo
</a>
</li>
<li>
@ -214,28 +219,28 @@
</li>
<li>
<a href="/settings">
<span class="icon"><i class="fa fa-edit"></i></span>
<span class="icon"><i class="fa fa-gear"></i></span>
Edit Profile &amp; Settings
</a>
</li>
<li>
<a href="/photo/certification">
<span class="icon"><i class="fa fa-certificate"></i></span>
Certification Photo
</a>
</li>
<li>
<a href="/users/blocked">
<span class="icon"><i class="fa fa-hand"></i></span>
Blocked Users
</a>
</li>
<li>
<a href="/notes/me">
<span class="icon"><i class="fa fa-pen-to-square mr-1"></i></span>
My User Notes
</a>
</li>
<li>
<a href="/photo/certification">
<span class="icon"><i class="fa fa-certificate"></i></span>
My Certification Photo
</a>
</li>
<li>
<a href="/users/blocked">
<span class="icon"><i class="fa fa-hand"></i></span>
Blocked Users
</a>
</li>
<li>
<a href="/logout">
<span class="icon"><i class="fa fa-arrow-right-from-bracket"></i></span>
@ -250,12 +255,6 @@
</a>
</li>
{{end}}
<li>
<a href="/settings#deactivate">
<span class="icon"><i class="fa fa-trash"></i></span>
Delete account
</a>
</li>
</ul>
</aside>
</div>
@ -487,9 +486,7 @@
<strong class="tag is-success">NEW!</strong>
</div>
{{end}}
<a href="/u/{{.AboutUser.Username}}">
{{template "avatar-48x48" .AboutUser}}
</a>
{{template "avatar-48x48" .AboutUser}}
</div>
<div class="column">
<div class="mb-1 pr-4">
@ -600,6 +597,11 @@
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>!
</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}}
{{.AboutUser.Username}} {{.Type}} {{.TableName}} {{.TableID}}
{{end}}
@ -628,6 +630,18 @@
<span class="icon"><i class="fa fa-arrow-right"></i></span>
<a href="{{.Link}}">See all comments</a>
</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}}
<em>{{or $Body.Photo.Caption "No caption."}}</em>
{{end}}

View File

@ -2,16 +2,18 @@
{{define "content"}}
{{template "profile-theme-style" .User}}
<div class="container">
<section class="hero {{if and .LoggedIn (not .IsPrivate)}}is-info{{else}}is-light is-bold{{end}}">
<section class="hero {{if and .LoggedIn (not .IsPrivate)}}is-info{{else}}is-light is-bold{{end}} {{if not .IsExternalView}}user-theme-hero{{end}}">
<div class="hero-body">
<div class="container">
<div class="columns">
<div class="column is-narrow has-text-centered">
<figure class="profile-photo is-inline-block">
{{if or (not .CurrentUser) .IsExternalView}}
<img src="{{.User.VisibleAvatarURL nil}}" data-photo-id="{{.User.ProfilePhoto.ID}}">
<img src="{{VisibleAvatarURL .User nil}}" data-photo-id="{{.User.ProfilePhoto.ID}}">
{{else}}
<img src="{{.User.VisibleAvatarURL .CurrentUser}}" data-photo-id="{{.User.ProfilePhoto.ID}}">
<a href="/u/{{.User.Username}}/photos">
<img src="{{VisibleAvatarURL .User .CurrentUser}}" data-photo-id="{{.User.ProfilePhoto.ID}}">
</a>
{{end}}
<!-- CurrentUser can upload a new profile pic -->
@ -311,43 +313,76 @@
<div class="column is-two-thirds">
<div class="card block">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
About Me
</p>
<header class="card-header has-background-link user-theme-card-title">
<div class="card-header-title">
<div class="columns is-mobile is-gapless nonshy-fullwidth">
<div class="column">
About Me
</div>
{{if eq .CurrentUser.ID .User.ID}}
<div class="column is-narrow">
<a href="/settings#profile/about_me" class="button is-outlined is-small">
<i class="fa fa-pencil"></i>
</a>
</div>
{{end}}
</div>
</div>
</header>
<div class="card-content">
<div class="card-content user-theme-card-body">
<div class="content">
{{or (ToMarkdown (.User.GetProfileField "about_me")) "n/a"}}
{{or (ReSignPhotoLinks (ToMarkdown (.User.GetProfileField "about_me"))) "n/a"}}
</div>
</div>
</div>
<div class="card block">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
My Interests
</p>
<header class="card-header has-background-link user-theme-card-title">
<div class="card-header-title">
<div class="columns is-mobile is-gapless nonshy-fullwidth">
<div class="column">
My Interests
</div>
{{if eq .CurrentUser.ID .User.ID}}
<div class="column is-narrow">
<a href="/settings#profile/interests" class="button is-outlined is-small">
<i class="fa fa-pencil"></i>
</a>
</div>
{{end}}
</div>
</div>
</header>
<div class="card-content">
<div class="card-content user-theme-card-body">
<div class="content">
{{or (ToMarkdown (.User.GetProfileField "interests")) "n/a"}}
{{or (ReSignPhotoLinks (ToMarkdown (.User.GetProfileField "interests"))) "n/a"}}
</div>
</div>
</div>
<div class="card block">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
Music/Bands/Movies
</p>
<header class="card-header has-background-link user-theme-card-title">
<div class="card-header-title">
<div class="columns is-mobile is-gapless nonshy-fullwidth">
<div class="column">
Music/Bands/Movies
</div>
{{if eq .CurrentUser.ID .User.ID}}
<div class="column is-narrow">
<a href="/settings#profile/music_movies" class="button is-outlined is-small">
<i class="fa fa-pencil"></i>
</a>
</div>
{{end}}
</div>
</div>
</header>
<div class="card-content">
<div class="card-content user-theme-card-body">
<div class="content">
{{or (ToMarkdown (.User.GetProfileField "music_movies")) "n/a"}}
{{or (ReSignPhotoLinks (ToMarkdown (.User.GetProfileField "music_movies"))) "n/a"}}
</div>
</div>
</div>
@ -355,14 +390,14 @@
<div class="column">
<div class="card block">
<header class="card-header has-background-info">
<header class="card-header has-background-info user-theme-card-title">
<p class="card-header-title has-text-light">
<i class="fa fa-user pr-2"></i>
About {{.User.Username}}
</p>
</header>
<div class="card-content">
<div class="card-content user-theme-card-body">
<table class="table is-fullwidth" style="font-size: small">
<tr>
<td class="has-text-right">
@ -441,14 +476,14 @@
</div>
<div class="card block">
<header class="card-header has-background-info">
<header class="card-header has-background-info user-theme-card-title">
<p class="card-header-title has-text-light">
<i class="fa fa-chart-line pr-2"></i>
Activity
</p>
</header>
<div class="card-content">
<div class="card-content user-theme-card-body">
<!-- Lazy load the statistics card-->
<div hx-get="/htmx/user/profile/activity?username={{.User.Username}}" hx-trigger="load">
@ -492,6 +527,17 @@
</li>
<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>
<a href="/admin/user-action?intent=essays&user_id={{.User.ID}}">
<span class="icon"><i class="fa fa-pencil"></i></span>

View File

@ -82,6 +82,15 @@
<a href="/faq#shy-faqs" target="_blank">Learn more <i class="fa fa-external-link"></i></a>
</p>
</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}}
<!-- Restricted search terms -->
@ -100,11 +109,17 @@
<p>
It is also against the <a href="/tos#child-exploitation">Terms of Service</a> of this website, and
members who violate this rule will be banned. This website is actively monitored to keep on top of this stuff,
members who violate this rule will be banned. <strong>This website is actively monitored</strong> to keep on top of this stuff,
and we cooperate enthusiastically with
<a href="https://www.missingkids.org/" title="National Center for Missing and Exploited Children">NCMEC</a>
and relevant law enforcement agencies.
</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>
{{end}}
@ -178,7 +193,7 @@
<div class="column pl-1">
<div class="field">
<label class="label" for="wcs">Location: <span class="tag is-success">New!</span></label>
<label class="label" for="wcs">Location:</label>
<input type="text" class="input"
name="wcs" id="wcs"
autocomplete="off"
@ -295,6 +310,14 @@
{{if .LikedSearch}}checked{{end}}>
Show only my "Likes"
</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>
@ -430,47 +453,48 @@
{{if .GetProfileField "orientation"}}
<span class="mr-2">{{.GetProfileField "orientation"}}</span>
{{end}}
<!-- Chat room status -->
{{if $Root.UserOnChatMap.Get .Username}}
<div>
<span class="tag is-success is-light">
<i class="fa fa-user mr-2"></i>
Currently on chat!
</span>
</div>
{{end}}
<!-- Show a subfooter based on ordered by -->
{{if eq $Root.Sort "last_login_at desc"}}
<div>
<small>
Last logged in:
<span title="On {{.LastLoginAt.Format "Jan _2 2006 15:04:05 MST"}}">
{{SincePrettyCoarse .LastLoginAt}} ago
</span>
</small>
</div>
{{end}}
{{if or (eq $Root.Sort "created_at desc") (eq $Root.Sort "certification_photos.updated_at desc")}}
<div>
<small>
Member since:
<span title="On {{.CreatedAt.Format "Jan _2 2006 15:04:05 MST"}}">
{{SincePrettyCoarse .CreatedAt}} ago
</span>
</small>
</div>
{{end}}
<!-- Ordered by distance? -->
{{if eq $Root.Sort "distance"}}
<div>
{{$Root.DistanceMap.Get .ID}} away
</div>
{{end}}
</p>
<!-- Chat room status -->
{{if $Root.UserOnChatMap.Get .Username}}
<div>
<span class="tag is-success is-light">
<i class="fa fa-user mr-2"></i>
Currently on chat!
</span>
</div>
{{end}}
<!-- Show a subfooter based on ordered by -->
{{if eq $Root.Sort "last_login_at desc"}}
<div>
<small>
Last logged in:
<span title="On {{.LastLoginAt.Format "Jan _2 2006 15:04:05 MST"}}">
{{SincePrettyCoarse .LastLoginAt}} ago
</span>
</small>
</div>
{{end}}
{{if or (eq $Root.Sort "created_at desc") (eq $Root.Sort "certification_photos.updated_at desc")}}
<div>
<small>
Member since:
<span title="On {{.CreatedAt.Format "Jan _2 2006 15:04:05 MST"}}">
{{SincePrettyCoarse .CreatedAt}} ago
</span>
</small>
</div>
{{end}}
<!-- Ordered by distance? -->
{{if eq $Root.Sort "distance"}}
<div>
{{$Root.DistanceMap.Get .ID}} away
</div>
{{end}}
</div>
</div><!-- media-block -->
</div>

View File

@ -307,7 +307,7 @@
</div>
</div>
<div class="field">
<div class="field" id="profile/about_me">
<label class="label" for="about_me">About Me</label>
<textarea class="textarea" cols="60" rows="4"
id="about_me"
@ -318,7 +318,7 @@
</p>
</div>
<div class="field">
<div class="field" id="profile/interests">
<label class="label" for="interests">My Interests</label>
<textarea class="textarea" cols="60" rows="4"
id="interests"
@ -326,7 +326,7 @@
placeholder="What kinds of things make you curious?">{{$User.GetProfileField "interests"}}</textarea>
</div>
<div class="field">
<div class="field" id="profile/music_movies">
<label class="label" for="music_movies">Music/Bands/Movies</label>
<textarea class="textarea" cols="60" rows="4"
id="music_movies"
@ -835,7 +835,7 @@
logged-out browser, they are prompted to log in.
</p>
<label class="checkbox mt-2">
<label class="checkbox mt-3">
<input type="radio"
name="visibility"
value="external"
@ -850,7 +850,7 @@
<a href="/u/{{.CurrentUser.Username}}?view=external" target="_blank">Preview <i class="fa fa-external-link"></i></a>
</p>
<label class="checkbox mt-2">
<label class="checkbox mt-3">
<input type="radio"
name="visibility"
value="private"
@ -871,15 +871,14 @@
<div class="field">
<label class="label mb-0">Who can send me the first <i class="fa fa-envelope"></i> Message?</label>
<div class="has-text-info ml-4">
<div class="has-text-info">
<small><em>
Note: this refers to Direct Messages on the main website
(not inside the chat room).
</em></small>
{{.CurrentUser.GetProfileField "dm_privacy"}}
</div>
<label class="checkbox">
<label class="checkbox mt-3">
<input type="radio"
name="dm_privacy"
value=""
@ -891,24 +890,26 @@
page (except for maybe <a href="/faq#shy-faqs" target="_blank">Shy Accounts</a>).
</p>
<label class="checkbox">
<label class="checkbox mt-3">
<input type="radio"
name="dm_privacy"
value="friends"
{{if eq (.CurrentUser.GetProfileField "dm_privacy") "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">
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.
</p>
<label class="checkbox">
<label class="checkbox mt-3">
<input type="radio"
name="dm_privacy"
value="nobody"
{{if eq (.CurrentUser.GetProfileField "dm_privacy") "nobody"}}checked{{end}}>
Nobody (close my DMs)
<i class="fa fa-hand has-text-danger ml-2"></i>
</label>
<p class="help">
Nobody can start a Direct Message conversation with you on the main website
@ -919,6 +920,77 @@
<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">
<button type="submit" class="button is-primary">
<i class="fa fa-save mr-2"></i> Save Privacy Settings
@ -1421,9 +1493,10 @@ window.addEventListener("DOMContentLoaded", (event) => {
// Global function to toggle the active tab.
const showTab = (name) => {
name = name.replace(/\.$/, '');
if (!name) name = "profile";
let tabName = name.split('/')[0]; // "#profile/about_me"
if (!tabName) name = "profile";
$activeTab.style.display = 'none';
switch (name) {
switch (tabName) {
case "look":
$activeTab = $look;
break;
@ -1486,9 +1559,9 @@ window.addEventListener("DOMContentLoaded", (event) => {
// Show the requested tab on first page load.
showTab(window.location.hash.replace(/^#/, ''));
window.requestAnimationFrame(() => {
window.scrollTo(0, 0);
});
// window.requestAnimationFrame(() => {
// window.scrollTo(0, 0);
// });
});
// Look & Feel tab scripts.
@ -1563,7 +1636,7 @@ window.addEventListener("DOMContentLoaded", (event) => {
$pushDeniedHelp = document.querySelector("#push-denied-help");
// Is the Notification API unavailable?
if (typeof(window.Notification) === "undefined") {
if (typeof(window.Notification) === "undefined" || typeof(PushNotificationSubscribe) === "undefined") {
$pushStatusDefault.innerHTML = `<i class="fa fa-xmark mr-1"></i> Notification API unavailable`;
$pushStatusDefault.style.display = "";
return;

View File

@ -187,11 +187,38 @@
<div class="card-content">
{{if .FeedbackPager.Total}}
<span>
<div class="block">
Found <strong>{{.FeedbackPager.Total}}</strong> report{{Pluralize64 .FeedbackPager.Total}} about this user (page {{.FeedbackPager.Page}} of {{.FeedbackPager.Pages}}).
</span>
</div>
{{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">
{{SimplePager .FeedbackPager}}
</div>
@ -224,29 +251,29 @@
{{if ne .TableID 0}} - {{.TableID}}{{end}}
{{else if eq .TableName "users"}}
Users: {{.TableID}}
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true"
<a href="/admin/feedback?id={{.ID}}&visit=true"
class="fa fa-external-link ml-2"
target="_blank"
title="Visit the reported user's profile"></a>
{{else if eq .TableName "photos"}}
Photos: {{.TableID}}
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true"
<a href="/admin/feedback?id={{.ID}}&visit=true"
class="fa fa-external-link mx-2"
target="_blank"
title="Visit the reported photo"></a>
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true&profile=true"
<a href="/admin/feedback?id={{.ID}}&visit=true&profile=true"
class="fa fa-user"
target="_blank"
title="Visit the user profile who owns the reported photo"></a>
{{else if eq .TableName "messages"}}
Messages: {{.TableID}}
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true"
<a href="/admin/feedback?id={{.ID}}&visit=true"
class="fa fa-ghost ml-2"
target="_blank"
title="Impersonate the reporter and view this message thread"></a>
{{else}}
{{.TableName}}: {{.TableID}}
<a href="{{$Root.Request.URL.Path}}?id={{.ID}}&visit=true" class="fa fa-external-link ml-2" target="_blank"></a>
<a href="/admin/feedback?id={{.ID}}&visit=true" class="fa fa-external-link ml-2" target="_blank"></a>
{{end}}
</td>
</tr>

View File

@ -177,9 +177,11 @@
<option value="Your certification pic should depict you holding onto a sheet of paper with your username, site name, and current date written on it.">
Didn't follow directions
</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="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="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="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="This is not an acceptable certification photo.">Not acceptable</option>
</optgroup>
@ -211,7 +213,8 @@
{{end}}
{{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>Approve</span>
</button>
@ -245,7 +248,7 @@
textarea.addEventListener("change", setApproveState);
textarea.addEventListener("keyup", setApproveState);
elem.addEventListener("change", (e) => {
textarea.value = elem.value;
textarea.value = elem.value.replaceAll("\\n", "\n");
setApproveState();
});
})

View File

@ -23,13 +23,13 @@
<div class="tabs is-toggle">
<ul>
<li{{if eq .Intent ""}} class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?acknowledged={{.Acknowledged}}">All</a>
<a href="{{.Request.URL.Path}}?{{QueryPlus "intent" ""}}">All</a>
</li>
<li{{if eq .Intent "contact"}} class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?acknowledged={{.Acknowledged}}&intent=contact">Contact</a>
<a href="{{.Request.URL.Path}}?{{QueryPlus "intent" "contact"}}">Contact</a>
</li>
<li{{if eq .Intent "report"}} class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?acknowledged={{.Acknowledged}}&intent=report">Reports</a>
<a href="{{.Request.URL.Path}}?{{QueryPlus "intent" "report"}}">Reports</a>
</li>
</ul>
</div>
@ -38,16 +38,93 @@
<div class="tabs is-toggle">
<ul>
<li{{if not .Acknowledged}} class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?intent={{.Intent}}">Unread</a>
<a href="{{.Request.URL.Path}}?{{QueryPlus "acknowledged" "false"}}">Unread</a>
</li>
<li{{if .Acknowledged}} class="is-active"{{end}}>
<a href="{{.Request.URL.Path}}?acknowledged=true&intent={{.Intent}}">Acknowledged</a>
<a href="{{.Request.URL.Path}}?{{QueryPlus "acknowledged" "true"}}">Acknowledged</a>
</li>
</ul>
</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">&nbsp;</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}}
<div class="columns is-multiline">
@ -146,11 +223,29 @@
</table>
<div class="content">
{{if eq .Message ""}}
<p><em>No message attached.</em></p>
{{else}}
{{ToMarkdown .Message}}
{{end}}
{{if eq .Message ""}}
<p><em>No message attached.</em></p>
{{else}}
{{ToMarkdown .Message}}
{{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>

View File

@ -12,6 +12,8 @@
</div>
</section>
{{$Root := .}}
<div class="block p-4">
<div class="columns is-centered">
<div class="column is-half">
@ -22,6 +24,9 @@
{{if eq .Intent "impersonate"}}
<i class="mr-2 fa fa-ghost"></i>
Impersonate User
{{else if eq .Intent "chat.rules"}}
<i class="mr-2 fa fa-gavel"></i>
Chat Moderation Rules
{{else if eq .Intent "essays"}}
<i class="mr-2 fa fa-pencil"></i>
Edit Profile Text
@ -99,6 +104,23 @@
<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="column">
<h5 class="has-text-warning">Forward List <span class="tag is-warning">{{len .BlocklistInsights.Blocks}}</span></h5>
@ -118,6 +140,9 @@
{{range .BlocklistInsights.Blocks}}
<li>
<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>
</li>
{{end}}
@ -141,6 +166,9 @@
{{range .BlocklistInsights.BlockedBy}}
<li>
<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>
</li>
{{end}}
@ -148,6 +176,45 @@
</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"}}
<div class="block content">
<p>

View File

@ -1,7 +1,6 @@
{{define "title"}}Untitled{{end}}
{{define "content"}}{{end}}
{{define "scripts"}}{{end}}
{{define "head-scripts"}}{{end}}
{{define "base"}}
<!DOCTYPE html>
<html lang="en">
@ -10,21 +9,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/static/css/bulma.min.css?build={{.BuildHash}}">
<!-- 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}}">
{{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}}">
{{else}}
{{- else -}}
<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/css/theme.css?build={{.BuildHash}}">
<link rel="manifest" href="/manifest.json">
<title>{{template "title" .}} - {{ .Title }}</title>
{{template "head-scripts" .}}
</head>
<body>
<nav class="navbar" role="navigation" aria-label="main navigation">
<body class="has-navbar-fixed-top">
<nav class="navbar is-fixed-top" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/">
{{ PrettyTitle }}
@ -381,7 +379,10 @@
<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/slim-forms.js?build={{.BuildHash}}"></script>
<script type="text/javascript" src="/static/js/web-push.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>
{{- end}}
{{template "scripts" .}}
<!-- Likes modal -->

View File

@ -28,7 +28,7 @@
<div class="block p-4">
{{if .CurrentUser.IsBirthday}}
<div class="notification is-success is-light content">
<h2>🎂 Happy birthday!</h2>
<span class="is-size-3">🎂 Happy birthday!</span>
<p>
If you would like, you may enter the chat room with a special 🍰 birthday cake emoji next to
@ -48,11 +48,27 @@
{{end}}
{{if .IsShyUser}}
<div class="notification is-danger is-light">
<i class="fa fa-exclamation-triangle"></i> You have a <strong>Shy Account</strong> and you may not enter
the chat room at this time, where our {{PrettyTitle}} members may be sharing their cameras. You are
sharing no public photos with the community, so you get limited access to ours.
<a href="/faq#shy-faqs">Learn more about how to resolve this issue. <small class="fa fa-external-link"></small></a>
<div class="notification is-warning is-light content">
<p>
<i class="fa fa-exclamation-triangle"></i> You have a <strong>Shy Account</strong>, so you will experience
limited functionality on the chat room:
</p>
<ul>
<li>You may not broadcast or watch any webcam on chat.</li>
<li>You may not share or see pictures shared by other members on chat.</li>
</ul>
<p>
This is because, as a Shy Account, you are not sharing any public photos with the community on your profile
page, so to most other members of {{PrettyTitle}} you appear to be a "blank, faceless profile" and people on
the chat room generally feel uncomfortable having their webcams be watched by such a Shy Account.
</p>
<p>
<a href="/faq#shy-faqs">Click here to learn more</a> about your Shy Account, including steps on how to
resolve this.
</p>
</div>
{{end}}
@ -90,6 +106,11 @@
<i class="fa fa-video has-text-danger"></i> <span id="cameraRed" class="has-text-danger">0</span>).
</span>
</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>
<h3>Chat Room Rules</h3>

View File

@ -0,0 +1,245 @@
{{define "title"}}A peek inside the {{.Title}} website{{end}}
{{define "content"}}
<div class="block">
<section class="hero is-light is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">A peek inside the {{PrettyTitle}} website</h1>
<h2 class="subtitle">Some statistics &amp; demographics of our members &amp; content</h2>
</div>
</div>
</section>
</div>
<div class="block p-4">
<div class="content">
<p>
This page provides some insights into the distribution of content and member demographics
in the {{PrettyTitle}} community.
</p>
<p>
If you are a prospective new member and are curious about whether this isn't actually "just another porn site,"
hopefully this page will help answer some of those questions for you.
</p>
<p>
We have found that {{PrettyTitle}} actually fills a much needed niche <em>in between</em> the two
opposite poles of the "strict naturist website" and the "hyper sexual porn sites" that exist elsewhere
online. Many of our members have come here from other major nudist websites, and we have so far maintained a
nice balance in content: <strong>most</strong> of what people share here are "normal nudes" that
don't contain any sexual undertones at all!
</p>
<p>
On our <a href="/features#webcam-chat-room">webcam chat room</a>, though we permit people to be sexual
on camera when they want to be, we typically maintain a balance where <em>most</em> of the webcams are
"blue" (or non-sexual in nature). Members are asked to mark their webcams as 'explicit' (red) when they are
being horny, so you may have informed consent as to whether you want to open their red cam and watch.
</p>
<p>
The insights on this page should hopefully back up this story with some hard numbers so you may get a preview
of what you may expect to find on this website should you decide to join us here.
</p>
<p>
{{PrettyTitle}} is open to <strong>all</strong> nudists &amp; exhibitionists and we'd love to have you join our community!
</p>
<p>
<strong><em>Last Updated:</em></strong> these website statistics were last collected
on {{.Demographic.LastUpdated.Format "Jan _2, 2006 @ 15:04:05 MST"}}
<!-- Admin: force refresh -->
{{if 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}}

View File

@ -3,7 +3,7 @@
<body bakground="#ffffff" color="#000000" link="#0000FF" vlink="#990099" alink="#FF0000">
<basefont face="Arial,Helvetica,sans-serif" size="3" color="#000000"></basefont>
<h1>Your certification photo has been rejected</h1>
<h1>Your certification photo has been denied</h1>
<p>Dear {{.Data.Username}},</p>

View File

@ -4,10 +4,33 @@
<div class="hero-body">
<div class="container">
<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>
</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">
<h1>Certification Required</h1>
<p>

View File

@ -701,6 +701,12 @@
content from other users -- by default this site is "normal nudes" friendly!
</p>
<p>
Please see the <a href="/tos#explicit-photos">Explicit Photos &amp; 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>
<p>
@ -1131,8 +1137,8 @@
<p>
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
all their pictures hidden are not permitted in the chat room at this time - but they may get
their own separate room later where they can bother other similarly shy members there.
all their pictures hidden MAY join the chat room, but have certain restrictions applied (such
as an inability to broadcast or watch any webcam, or share or see any picture shared on chat).
</p>
<p>
@ -1400,9 +1406,9 @@
your Friend or have shared their private pictures with you.
</li>
<li>
You can not join the <i class="fa fa-message"></i> <strong>Chat Room</strong>. You guys
may soon get your own chat room, though. Many of us {{PrettyTitle}} nudists would not
enjoy our webcams being watched by blank profiles.
You <strong>can</strong> join the <i class="fa fa-message"></i> <strong>Chat Room</strong>, however
some features will be restricted to Shy Accounts: you will not be able to broadcast OR watch any webcam
on chat, nor can you share OR view any photo shared by others on the chat room.
</li>
</ul>
@ -1413,6 +1419,13 @@
kept with the other blank profiles until you choose to participate.
</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>
<p>
@ -1421,6 +1434,10 @@
</p>
<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>
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.

View File

@ -140,18 +140,16 @@
</p>
{{end}}
{{if .CurrentUser.HasAdminScope "admin.forum.manage"}}
<label class="checkbox mt-3">
<input type="checkbox"
name="permit_photos"
value="true"
{{if and .EditForum .EditForum.PermitPhotos}}checked{{end}}>
Permit Photos <i class="fa fa-camera ml-1"></i> <i class="fa fa-peace has-text-danger ml-1"></i>
Permit Photos <i class="fa fa-camera ml-1"></i>
</label>
<p class="help">
Check this box if the forum allows photos to be uploaded (not implemented)
Check this box if the forum allows photos to be uploaded.
</p>
{{end}}
{{if .CurrentUser.HasAdminScope "admin.forum.manage"}}
<label class="checkbox mt-3">

View File

@ -203,7 +203,13 @@
</h3>
<!-- 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}}>
{{end}}
</div>
{{end}}

View File

@ -140,16 +140,29 @@
</div>
[unavailable]
{{else}}
<!-- User has no display name distinct from their username? -->
{{ $NoDisplayName := eq $c.User.NameOrUsername $c.User.Username }}
<div>
<a href="/u/{{$c.User.Username}}">
{{template "avatar-96x96" $c.User}}
</a>
</div>
<a href="/u/{{$c.User.Username}}">{{$c.User.NameOrUsername}}</a>
<a href="/u/{{$c.User.Username}}">
{{- if $NoDisplayName}}<small class="is-size-7">@</small>{{end -}}
{{$c.User.NameOrUsername}}
</a>
<!-- Username if the display name wasn't identical -->
{{if not $NoDisplayName}}
<div class="is-size-7">
@{{$c.User.Username}}
</div>
{{end}}
{{end}}
{{if $c.User.IsAdmin}}
<div class="is-size-7 mt-1">
<div class="is-size-7 mt-2">
<span class="tag is-danger is-light">
<span class="icon"><i class="fa fa-peace"></i></span>
<span>Admin</span>
@ -306,14 +319,14 @@
{{if not $Root.Thread.NoReply}}
<div class="column is-narrow">
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}&quote={{.ID}}"
class="has-text-dark nonshy-quote-button" data-quote-body="{{.Message}}" data-reply-to="{{.User.Username}}">
class="has-text-dark nonshy-quote-button" data-quote-body="{{.Message}}" data-reply-to="{{.User.Username}}" data-comment-id="{{.ID}}">
<span class="icon"><i class="fa fa-quote-right"></i></span>
<span>Quote</span>
</a>
</div>
<div class="column is-narrow">
<a href="/forum/post?to={{$Root.Forum.Fragment}}&thread={{$Root.Thread.ID}}"
class="has-text-dark nonshy-reply-button" data-reply-to="{{.User.Username}}">
class="has-text-dark nonshy-reply-button" data-reply-to="{{.User.Username}}" data-comment-id="{{.ID}}">
<span class="icon"><i class="fa fa-reply"></i></span>
<span>Reply</span>
</a>

View File

@ -69,18 +69,7 @@
the main website and the chat room.
</div>
<div class="columns is-mobile">
<div class="column">
<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>
<button type="submit" class="button is-success">Send Reply</button>
</form>
@ -151,15 +140,22 @@
<!-- Our message? We can delete it. -->
{{if eq $Root.CurrentUser.ID $SourceUser.ID}}
<form action="/messages/delete" method="POST" class="is-inline" onsubmit="return confirm('Delete this message?')">
<form action="/messages/delete" method="POST" class="is-inline"
onsubmit="return confirm('Do you want to DELETE this message?')">
{{InputCSRF}}
<input type="hidden" name="id" value="{{.ID}}">
<input type="hidden" name="next" value="{{$Root.Request.URL.Path}}">
<button class="button has-text-danger is-outline is-small p-1 ml-4">
<button class="button is-outline has-text-grey is-small ml-4">
<i class="fa fa-trash mr-2"></i>
Delete
</button>
</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}}
</div>

View File

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

View File

@ -24,97 +24,84 @@ section.hero {
{{$cardTitleFG := or (.GetProfileField "card-title-fg") "#f7f7f7"}}
{{$cardLinkFG := or (.GetProfileField "card-link-color") "#0099ff"}}
{{$cardLightness := .GetProfileField "card-lightness"}}
{{$heroA := or (.GetProfileField "hero-color-start") "#0f81cc"}}
{{$heroB := or (.GetProfileField "hero-color-end") "#7683cc"}}
<style type="text/css">
{{template "profile-theme-hero-style" .}}
header.card-header {
/* Hero banner */
.user-theme-hero {
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;
}
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"}}
div.box, .container div.card-content, table.table, table.table strong, td {
background-color: #fff !important;
color: #4a4a4a !important;
}
aside.menu ul.menu-list li a {
background-color: #ccc !important;
color: #4a4a4a !important;
}
blockquote, pre, code {
background-color: #ccc !important;
color: #4a4a4a;
}
div.tag {
background-color: #ccc;
color: #4a4a4a;
}
strong {
color: #4a4a4a;
}
.user-theme-card-body {
background-color: #fff !important;
color: #4a4a4a !important;
/* More text color overrides (h1's etc. look light on prefers-dark color schemes) */
.container div.card-content .content * {
color: #4a4a4a;
}
* {
color: #4a4a4a !important;
}
/* 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;
a > * {
color: {{$cardLinkFG}} !important;
}
blockquote, pre, code, .tag {
background-color: #ccc !important;
color: #4a4a4a m !important;
}
.table {
background-color: inherit;
}
}
}
{{else if eq $cardLightness "dark"}}
div.box, .container div.card-content, table.table, table.table strong, td {
background-color: #4a4a4a !important;
color: #f5f5f5 !important;
}
aside.menu ul.menu-list li a {
background-color: #1a1a1a !important;
color: #f5f5f5 !important;
}
blockquote, pre, code {
background-color: #1a1a1a !important;
color: #f5f5f5;
}
div.tag {
background-color: #333;
color: #f5f5f5;
}
strong {
color: #f5f5f5;
}
.user-theme-card-body {
background-color: #4a4a4a !important;
color: #f5f5f5 !important;
/* More text color overrides (h1's etc. look dark on prefers-light color schemes) */
.container div.card-content .content * {
color: #f5f5f5;
}
* {
color: #f5f5f5 !important;
}
/* 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;
}
}
a > * {
color: {{$cardLinkFG}} !important;
}
blockquote, pre, code, .tag {
background-color: #1a1a1a !important;
color: #f5f5f5 m !important;
}
.table {
background-color: inherit;
}
}
{{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>
{{end}}

View File

@ -0,0 +1,169 @@
{{define "title"}}Delete Photo{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-link is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
{{if eq .Intent "delete"}}
<i class="fa fa-trash mr-2"></i>
Delete {{len .Photos}} Photo{{Pluralize (len .Photos)}}
{{else if eq .Intent "visibility"}}
<i class="fa fa-eye mr-2"></i>
Edit Visibility
{{else}}
Batch Edit Photos
{{end}}
</h1>
</div>
</div>
</section>
<div class="block p-4">
<div class="level">
<div class="level-item">
<div class="card" style="max-width: 800px">
<header class="card-header {{if eq .Intent "delete"}}has-background-danger{{else}}has-background-link{{end}}">
<p class="card-header-title has-text-light">
{{if eq .Intent "delete"}}
<span class="icon"><i class="fa fa-trash"></i></span>
Delete {{len .Photos}} Photo{{Pluralize (len .Photos)}}
{{else if eq .Intent "visibility"}}
<span class="icon"><i class="fa fa-eye mr-2"></i></span>
Edit Visibility
{{else}}
Batch Edit Photos
{{end}}
</p>
</header>
<div class="card-content">
<form method="POST" action="/photo/batch-edit">
{{InputCSRF}}
<input type="hidden" name="intent" value="{{.Intent}}">
<input type="hidden" name="confirm" value="true">
<!-- Bulk Visibility Settings -->
{{if eq .Intent "visibility"}}
<p>
You may use this page to set <strong>all ({{len .Photos}}) photo{{if ge (len .Photos) 2}}s'{{end}}</strong>
visibility setting.
</p>
<!-- TODO: copy/pasted block from the Upload page -->
<div class="field">
<label class="label">Photo Visibility</label>
<div>
<label class="radio">
<input type="radio"
name="visibility"
value="public"
{{if or (not .EditPhoto) (eq .EditPhoto.Visibility "public")}}checked{{end}}>
<strong class="has-text-link ml-1">
<span>Public <small>(members only)</small></span>
<span class="icon"><i class="fa fa-eye"></i></span>
</strong>
</label>
<p class="help">
This photo will appear on your profile page and can be seen by any
logged-in user account. It may also appear on the site-wide Photo
Gallery if that option is enabled, below.
</p>
</div>
<div>
<label class="radio">
<input type="radio"
name="visibility"
value="friends"
{{if eq .EditPhoto.Visibility "friends"}}checked{{end}}>
<strong class="has-text-warning ml-1">
<span>Friends only</span>
<span class="icon"><i class="fa fa-user-group"></i></span>
</strong>
</label>
<p class="help">
Only users you have accepted as a friend can see this photo on your
profile page and on the site-wide Photo Gallery if that option is
enabled, below.
</p>
</div>
<div>
<label class="radio">
<input type="radio"
name="visibility"
value="private"
{{if eq .EditPhoto.Visibility "private"}}checked{{end}}>
<strong class="has-text-private ml-1">
<span>Private</span>
<span class="icon"><i class="fa fa-lock"></i></span>
</strong>
</label>
<p class="help">
This photo is visible only to you and to users for whom you have
granted access
(<a href="/photo/private" target="_blank" class="has-text-private">manage grants <i class="fa fa-external-link"></i></a>).
</p>
</div>
<div class="has-text-warning is-size-7 mt-4">
<i class="fa fa-info-circle mr-1"></i>
<strong class="has-text-warning">Reminder:</strong> There are risks inherent with sharing
pictures on the Internet, and {{PrettyTitle}} can't guarantee that another member of the site
won't download and possibly redistribute your photos. You may mark your picture as "Friends only"
or "Private" to limit who on the website will see it, but anybody who <em>can</em> see it could potentially
save it to their computer. <a href="/faq#downloading" target="_blank">Learn more <i class="fa fa-external-link"></i></a>
</div>
</div>
{{end}}
<!-- Show range of photos on all updates -->
<div class="columns is-mobile is-multiline">
{{range .Photos}}
<div class="column is-half">
<input type="hidden" name="id" value="{{.ID}}">
<div class="image block">
<!-- GIF video? -->
{{if HasSuffix .Filename ".mp4"}}
<video autoplay loop controls controlsList="nodownload" playsinline>
<source src="{{PhotoURL .Filename}}" type="video/mp4">
</video>
{{else}}
<img src="{{PhotoURL .Filename}}">
{{end}}
</div>
</div>
{{end}}
</div>
<div class="block">
Are you sure you want to
{{if eq .Intent "delete"}}
<strong class="has-text-danger">delete</strong>
{{else if eq .Intent "visibility"}}
<strong>update the visibility</strong> of
{{else}}
update
{{end}}
{{if ge (len .Photos) 2 -}}
these <strong>{{len .Photos}} photos?</strong>
{{- else -}}
this photo?
{{- end}}
</div>
<div class="block has-text-center">
{{if eq .Intent "delete"}}
<button type="submit" class="button is-danger">Delete Photo{{Pluralize (len .Photos)}}</button>
{{else}}
<button type="submit" class="button is-primary">Update Photo{{Pluralize (len .Photos)}}</button>
{{end}}
<button type="button" class="button" onclick="history.back()">Cancel</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}

View File

@ -17,6 +17,12 @@
{{define "card-body"}}
<div>
<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 class="mt-2">
{{if .Pinned}}
@ -67,6 +73,11 @@
<!-- Reusable 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}}">
<span class="icon"><i class="fa fa-edit"></i></span>
<span>Edit</span>
@ -414,6 +425,9 @@
{{end}}
<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="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>
</div>
</div>
@ -457,18 +471,21 @@
</a>
</div>
{{else if not .IsSiteGallery}}
<div class="block">
{{if not .IsMyPrivateUnlockedFor}}
<a href="/photo/private/share?to={{.User.Username}}" class="has-text-private">
<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>
</a>
{{else}}
<span class="icon"><i class="fa fa-unlock has-text-private"></i></span>
<span>You had granted <strong>{{.User.Username}}</strong> access to see <strong>your</strong> private photos.</span>
<a href="/photo/private">Manage that here.</a>
<!-- 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">
<a href="/photo/private/share?to={{.User.Username}}" class="has-text-private">
<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>
</a>
</div>
{{end}}
</div>
{{end}}
{{if .AreWeGrantedPrivate}}
@ -481,6 +498,9 @@
{{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) -->
{{if eq .ViewStyle "full"}}
{{range .Photos}}
@ -533,6 +553,8 @@
<!-- GIF video? -->
{{if HasSuffix .Filename ".mp4"}}
<video loop controls controlsList="nodownload" playsinline
class="js-modal-trigger"
data-url="{{PhotoURL .Filename}}" data-photo-id="{{.ID}}"
{{if .AltText}}title="{{.AltText}}"{{end}}
{{if BlurExplicit .}}class="blurred-explicit"
{{else if (not (eq ($Root.CurrentUser.GetProfileField "autoplay_gif") "false"))}}autoplay
@ -540,8 +562,8 @@
<source src="{{PhotoURL .Filename}}" type="video/mp4">
</video>
{{else}}
<a href="/photo/view?id={{.ID}}" data-url="{{PhotoURL .Filename}}" target="_blank"
class="js-modal-trigger" data-target="detail-modal">
<a href="/photo/view?id={{.ID}}" data-url="{{PhotoURL .Filename}}" data-photo-id="{{.ID}}" target="_blank"
class="js-modal-trigger">
<img src="{{PhotoURL .Filename}}" loading="lazy"
{{if BlurExplicit .}}class="blurred-explicit"{{end}}
{{if .AltText}}alt="{{.AltText}}" title="{{.AltText}}"{{end}}>
@ -557,7 +579,7 @@
{{template "card-body" .}}
<!-- Quick mark photo as explicit -->
{{if and (not .Explicit) (ne .UserID $Root.CurrentUser.ID)}}
{{if and (not .Explicit) (ne .UserID $Root.CurrentUser.ID) (not .HasAdminLabelNonExplicit)}}
<div class="mt-2">
<a href="#"
class="has-text-danger is-size-7 nonshy-mark-explicit"
@ -658,6 +680,8 @@
<!-- GIF video? -->
{{if HasSuffix .Filename ".mp4"}}
<video loop controls controlsList="nodownload" playsinline
class="js-modal-trigger"
data-url="{{PhotoURL .Filename}}" data-photo-id="{{.ID}}"
{{if .AltText}}title="{{.AltText}}"{{end}}
{{if BlurExplicit .}}class="blurred-explicit"
{{else if (not (eq ($Root.CurrentUser.GetProfileField "autoplay_gif") "false"))}}autoplay
@ -665,8 +689,8 @@
<source src="{{PhotoURL .Filename}}" type="video/mp4">
</video>
{{else}}
<a href="/photo/view?id={{.ID}}" data-url="{{PhotoURL .Filename}}" target="_blank"
class="js-modal-trigger" data-target="detail-modal">
<a href="/photo/view?id={{.ID}}" data-url="{{PhotoURL .Filename}}" data-photo-id="{{.ID}}" target="_blank"
class="js-modal-trigger">
<img src="{{PhotoURL .Filename}}" loading="lazy"
{{if BlurExplicit .}}class="blurred-explicit"{{end}}
{{if .AltText}}alt="{{.AltText}}" title="{{.AltText}}"{{end}}>
@ -681,7 +705,7 @@
{{template "card-body" .}}
<!-- Quick mark photo as explicit -->
{{if and (not .Explicit) (ne .UserID $Root.CurrentUser.ID)}}
{{if and (not .Explicit) (ne .UserID $Root.CurrentUser.ID) (not .HasAdminLabelNonExplicit)}}
<div class="mt-2">
<a href="#"
class="has-text-danger is-size-7 nonshy-mark-explicit"
@ -740,22 +764,117 @@
{{SimplePager .Pager}}
<!-- Admin change log link -->
{{if .CurrentUser.HasAdminScope "admin.changelog"}}
<div class="block">
<a href="/admin/changelog?table_name=photos{{if .User}}&about_user_id={{.User.ID}}{{end}}" class="button is-small has-text-warning">
<span class="icon"><i class="fa fa-peace mr-1"></i></span>
<span>{{if .User}}User{{else}}Site{{end}} Gallery change log</span>
</a>
<!-- 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>
</div>
<!-- Admin change log link -->
{{if .CurrentUser.HasAdminScope "admin.changelog"}}
<div class="block">
<a href="/admin/changelog?table_name=photos{{if .User}}&about_user_id={{.User.ID}}{{end}}" class="button is-small has-text-warning">
<span class="icon"><i class="fa fa-peace mr-1"></i></span>
<span>{{if .User}}User{{else}}Site{{end}} Gallery change log</span>
</a>
</div>
{{end}}
</div>
</div>
<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", () => {
// Get our modal to trigger it on click of a detail img.
let $modal = document.querySelector("#detail-modal"),
@ -779,14 +898,60 @@
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 => {
let $img = node.getElementsByTagName("img"),
$video = node.tagName === 'VIDEO' ? node : null,
photoID = node.dataset.photoId,
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) => {
e.preventDefault();
setModalImage(node.dataset.url, altText);
$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>

View File

@ -100,11 +100,27 @@
<!-- Timestamp -->
<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>
</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 -->
{{if and (not .Photo.Explicit) (ne .Photo.UserID .CurrentUser.ID)}}
{{if and (not .Photo.Explicit) (ne .Photo.UserID .CurrentUser.ID) (not .Photo.HasAdminLabelNonExplicit)}}
<div class="mt-1">
<a href="#"
class="has-text-danger is-size-7 nonshy-mark-explicit"
@ -162,7 +178,7 @@
<!-- Report button except on your own pic -->
{{if not .IsOwnPhoto}}
<div class="column is-narrow ml-2">
<div class="column is-narrow mb-1">
<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>Report</span>
@ -180,7 +196,7 @@
<span>Change Log</span>
</a>
</div>
</aside>
</div>
{{end}}
</div>
@ -264,12 +280,25 @@
<div class="box has-background-link-light has-text-dark" id="p{{.ID}}">
<div class="columns">
<div class="column is-2 has-text-centered">
<!-- User has no display name distinct from their username? -->
{{ $NoDisplayName := eq .User.NameOrUsername .User.Username }}
<div>
<a href="/u/{{.User.Username}}">
{{template "avatar-96x96" .User}}
</a>
</div>
<a href="/u/{{.User.Username}}">{{.User.NameOrUsername}}</a>
<a href="/u/{{.User.Username}}">
{{- 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 class="column content">
@ -325,14 +354,14 @@
<div class="column is-narrow">
<a href="#"
class="has-text-dark nonshy-quote-button" data-quote-body="{{.Message}}" data-reply-to="{{.User.Username}}">
class="has-text-dark nonshy-quote-button" data-quote-body="{{.Message}}" data-reply-to="{{.User.Username}}" data-comment-id="{{.ID}}">
<span class="icon"><i class="fa fa-quote-right"></i></span>
<span>Quote</span>
</a>
</div>
<div class="column is-narrow">
<a href="#"
class="has-text-dark nonshy-reply-button" data-reply-to="{{.User.Username}}">
class="has-text-dark nonshy-reply-button" data-reply-to="{{.User.Username}}" data-comment-id="{{.ID}}">
<span class="icon"><i class="fa fa-reply"></i></span>
<span>Reply</span>
</a>

View File

@ -50,6 +50,21 @@
(page {{.Pager.Page}} of {{.Pager.Pages}}).
</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}}
<div class="columns is-gapless is-centered">
<div class="column is-narrow mx-1 my-2">
@ -86,27 +101,66 @@
<div class="media block">
<div class="media-left">
{{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 class="media-content">
<p class="title is-4">
<a href="/u/{{.Username}}" class="has-text-dark">{{.NameOrUsername}}</a>
<a href="/u/{{.Username}}" class="has-text-dark">
{{.NameOrUsername}}
</a>
{{if eq .Visibility "private"}}
<sup class="fa fa-mask is-size-7" title="Private Profile"></sup>
{{end}}
</p>
<p class="subtitle is-6 mb-1">
<span class="icon"><i class="fa fa-user"></i></span>
<a href="/u/{{.Username}}">{{.Username}}</a>
<!-- Not Certified or Shy Account badge -->
{{if not .Certified}}
<span class="has-text-danger">
<span class="icon"><i class="fa fa-certificate"></i></span>
<span class="has-text-danger is-size-7">
<i class="fa fa-certificate"></i>
<span>Not Certified!</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}}
{{if .IsAdmin}}
<span class="has-text-danger">
<span class="icon"><i class="fa fa-peace"></i></span>
<span class="tag is-danger is-light p-1" style="font-size: x-small">
<i class="fa fa-peace mr-1"></i>
<span>Admin</span>
</span>
{{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>
<!-- Indicator if they are sharing back -->
@ -144,7 +198,17 @@
</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">
<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?')">

View File

@ -17,6 +17,7 @@
</div>
</section>
{{ $Root := . }}
{{ $User := .CurrentUser }}
<!-- Drag/Drop Modal -->
@ -264,7 +265,6 @@
<label class="label">
<i class="fa fa-thumbtack mr-1 has-text-success"></i>
Pinned Photo
<span class="tag is-success ml-2">New!</span>
</label>
<label class="checkbox">
<input type="checkbox"
@ -391,6 +391,34 @@
<span>Explicit Content</span>
<span class="icon"><i class="fa fa-fire"></i></span>
</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"}}
<span class="has-text-danger">
Your default profile picture should
@ -405,19 +433,24 @@
that to your page, just not as your default profile picture!
</p>
{{else}}
<label class="checkbox">
<input type="checkbox"
name="explicit"
value="true"
{{if .EditPhoto.Explicit}}checked{{end}}>
This photo contains explicit content
</label>
<p class="help">
Mark this box if this photo contains any explicit content, including an
erect penis, close-up of genitalia, or any depiction of sexual activity.
Use your best judgment. "Normal nudes" such as full body nudes in a
non-sexual context do not need to check this box.
</p>
<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"
name="explicit"
value="true"
{{if .EditPhoto.Explicit}}checked{{end}}
{{if .EditPhoto.HasAdminLabelForceExplicit}}disabled{{end}}>
{{if $IsFlagged}}<del class="cursor-not-allowed">{{end}}
This photo contains explicit content
{{if $IsFlagged}}</del>{{end}}
</label>
<p class="help">
Mark this box if this photo contains any explicit content, including an
erect penis, close-up of genitalia, or any depiction of sexual activity.
Use your best judgment. "Normal nudes" such as full body nudes in a
non-sexual context do not need to check this box.
</p>
{{end}}
</div>
@ -457,6 +490,44 @@
</button>
</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>

View File

@ -380,20 +380,20 @@
</p>
<p>
A photo is considered "explicit" if it depicts <em>any</em> of the following features:
A photo is considered "explicit" if it depicts <strong>any</strong> of the following features:
</p>
<ul>
<li>
A close-up view of genitalia or where the genitals are the central focus of the picture.
</li>
<li>An erect penis if the subject has one, especially if they are grabbing it.</li>
<li>An erect or semi-erect penis if the subject has one, especially if they are grabbing it.</li>
<li>
"Spread eagle" pictures that clearly and especially show intimate body parts such
as butt holes or vulvae.
</li>
<li>
A depiction of a sexual act, including but not limited to: masturbation, oral sex,
A depiction of any sexual activity, including but not limited to: masturbation, oral sex,
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.
</li>
@ -405,6 +405,17 @@
</li>
</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>
<p>
@ -414,6 +425,7 @@
<ul>
<li>
<strong>Illegal content:</strong>
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
sexual acts involving animals), child sexually abusive material (CSAM), ANY nude photo
@ -423,6 +435,7 @@
or other unlawful content.
</li>
<li>
<strong>Extreme content:</strong>
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
(any depiction of obviously apparent fecal matter), prolapsed rectum, anal fisting,