Compare commits
No commits in common. "main" and "user-forums" have entirely different histories.
main
...
user-forum
104
README.md
104
README.md
|
@ -20,6 +20,20 @@ The website can also run out of a local SQLite database which is convenient
|
|||
for local development. The production server runs on PostgreSQL and the
|
||||
web app is primarily designed for that.
|
||||
|
||||
### PostGIS Extension for PostgreSQL
|
||||
|
||||
For the "Who's Nearby" feature to work you will need a PostgreSQL
|
||||
database with the PostGIS geospatial extension installed. Usually
|
||||
it might be a matter of `dnf install postgis` and activating the
|
||||
extension on your nonshy database as your superuser (postgres):
|
||||
|
||||
```psql
|
||||
create extension postgis;
|
||||
```
|
||||
|
||||
If you get errors like "Type geography not found" from Postgres when
|
||||
running distance based searches, this is the likely culprit.
|
||||
|
||||
## Building the App
|
||||
|
||||
This app is written in Go: [go.dev](https://go.dev). You can probably
|
||||
|
@ -47,96 +61,6 @@ a database.
|
|||
For simple local development, just set `"UseSQLite": true` and the
|
||||
app will run with a SQLite database.
|
||||
|
||||
### Postgres is Highly Recommended
|
||||
|
||||
This website is intended to run under PostgreSQL and some of its
|
||||
features leverage Postgres specific extensions. For quick local
|
||||
development, SQLite will work fine but some website features will
|
||||
be disabled and error messages given. These include:
|
||||
|
||||
* Location features such as "Who's Nearby" (PostGIS extension)
|
||||
* "Newest" tab on the forums: to deduplicate comments by most recent
|
||||
thread depends on Postgres, SQLite will always show all latest
|
||||
comments without deduplication.
|
||||
|
||||
### PostGIS Extension for PostgreSQL
|
||||
|
||||
For the "Who's Nearby" feature to work you will need a PostgreSQL
|
||||
database with the PostGIS geospatial extension installed. Usually
|
||||
it might be a matter of `dnf install postgis` and activating the
|
||||
extension on your nonshy database as your superuser (postgres):
|
||||
|
||||
```psql
|
||||
create extension postgis;
|
||||
```
|
||||
|
||||
If you get errors like "Type geography not found" from Postgres when
|
||||
running distance based searches, this is the likely culprit.
|
||||
|
||||
### Signed Photo URLs (NGINX)
|
||||
|
||||
The website supports "signed photo" URLs that can help protect the direct
|
||||
links to user photos (their /static/photos/*.jpg paths) to ensure only
|
||||
logged-in and authorized users are able to access those links.
|
||||
|
||||
This feature is not enabled (enforcing) by default, as it relies on
|
||||
cooperation with the NGINX reverse proxy server
|
||||
(module ngx_http_auth_request).
|
||||
|
||||
In your NGINX config, set your /static/ path to leverage NGINX auth_request
|
||||
like so:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
# your boilerplate server info (SSL, etc.) - not relevant to this example.
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
|
||||
# Relevant: setting the /static/ URL on NGINX to be an alias to your local
|
||||
# nonshy static folder on disk. In this example, the git clone for the
|
||||
# website was at /home/www-user/git/nonshy/website, so that ./web/static/
|
||||
# is the local path where static files (e.g., photos) are uploaded.
|
||||
location /static/ {
|
||||
# Important: auth_request tells NGINX to do subrequest authentication
|
||||
# on requests into the /static/ URI of your website.
|
||||
auth_request /static-auth;
|
||||
|
||||
# standard NGINX alias commands.
|
||||
alias /home/www-user/git/nonshy/website/web/static/;
|
||||
autoindex off;
|
||||
}
|
||||
|
||||
# Configure the internal subrequest auth path.
|
||||
# Note: the path "/static-auth" can be anything you want.
|
||||
location = /static-auth {
|
||||
internal; # this is an internal route for NGINX only, not public
|
||||
|
||||
# Proxy to the /v1/auth/static URL on the web app.
|
||||
# This line assumes the website runs on localhost:8080.
|
||||
proxy_pass http://localhost:8080/v1/auth/static;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
|
||||
# Important: the X-Original-URI header tells the web app what the
|
||||
# original path (e.g. /static/photos/*) was, so the web app knows
|
||||
# which sub-URL to enforce authentication on.
|
||||
proxy_set_header X-Original-URI $request_uri;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When your NGINX config is set up like the above, you can edit the
|
||||
settings.json to mark SignedPhoto/Enabled=true, and restart the
|
||||
website. Be sure to test it!
|
||||
|
||||
On a photo gallery view, all image URLs under /static/photos/ should
|
||||
come with a ?jwt= parameter, and the image should load for the current
|
||||
user. The JWT token is valid for 30 seconds after which the direct link
|
||||
to the image should expire and give a 403 Forbidden response.
|
||||
|
||||
When this feature is NOT enabled/not enforcing: the jwt= parameter is
|
||||
still generated on photo URLs but is not enforced by the web app.
|
||||
|
||||
## Usage
|
||||
|
||||
The `nonshy` binary has sub-commands to either run the web server
|
||||
|
|
|
@ -261,21 +261,6 @@ func main() {
|
|||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "photo-counts",
|
||||
Usage: "repopulate cached Likes and Comment counts on photos",
|
||||
Action: func(c *cli.Context) error {
|
||||
initdb(c)
|
||||
|
||||
log.Info("Running BackfillPhotoCounts()")
|
||||
err := backfill.BackfillPhotoCounts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
|
|
@ -37,10 +37,9 @@ func MaybeDisconnectUser(user *models.User) (bool, error) {
|
|||
},
|
||||
{
|
||||
If: user.IsShy(),
|
||||
Message: because + "you had updated your nonshy profile to become too private, and now are considered to have a 'Shy Account.'<br><br>" +
|
||||
"You may <strong>refresh</strong> the page to log back into chat as a Shy Account, where your ability to use webcams and share photos " +
|
||||
"will be restricted. To regain full access to the chat room, please edit your profile settings to make sure that at least one 'public' " +
|
||||
"photo is viewable to other members of the website.<br><br>" +
|
||||
Message: because + "you had updated your nonshy profile to become too private.<br><br>" +
|
||||
"You may join the chat room after you have made your profile and (at least some) pictures " +
|
||||
"viewable on 'public' so that you won't appear to be a blank, faceless profile to others on the chat room.<br><br>" +
|
||||
"Please see the <a href=\"https://www.nonshy.com/faq#shy-faqs\">Shy Account FAQ</a> for more information.",
|
||||
},
|
||||
{
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
// Admin Labels.
|
||||
const (
|
||||
// Admin Labels for Photos
|
||||
AdminLabelPhotoNonExplicit = "non-explicit"
|
||||
AdminLabelPhotoForceExplicit = "force-explicit"
|
||||
)
|
||||
|
||||
var (
|
||||
AdminLabelPhotoOptions = []ChecklistOption{
|
||||
{
|
||||
Value: AdminLabelPhotoNonExplicit,
|
||||
Label: "This is not an Explicit photo",
|
||||
Help: "Hide the prompt 'Should this photo be marked as explicit?' as this photo does not NEED to be Explicit. " +
|
||||
"Note: the owner of this photo MAY still mark it explicit if they want to.",
|
||||
},
|
||||
{
|
||||
Value: AdminLabelPhotoForceExplicit,
|
||||
Label: "Force this photo to be marked as Explicit",
|
||||
Help: "Enabling this option will force the Explicit tag to stay on, and not allow the user to remove it.",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// HasAdminLabel checks if a comma-separated set of admin labels contains the label.
|
||||
func HasAdminLabel(needle string, haystack string) bool {
|
||||
labels := strings.Split(haystack, ",")
|
||||
for _, label := range labels {
|
||||
if strings.TrimSpace(label) == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
|
@ -10,11 +10,11 @@ import (
|
|||
// returned by the scope list function.
|
||||
func TestAdminScopesCount(t *testing.T) {
|
||||
var scopes = config.ListAdminScopes()
|
||||
if len(scopes) != config.QuantityAdminScopes || len(scopes) != len(config.AdminScopeDescriptions)-1 {
|
||||
if len(scopes) != config.QuantityAdminScopes || len(scopes) != len(config.AdminScopeDescriptions) {
|
||||
t.Errorf(
|
||||
"The list of scopes returned by ListAdminScopes doesn't match the expected count. "+
|
||||
"Expected %d (with %d descriptions), got %d",
|
||||
config.QuantityAdminScopes, len(config.AdminScopeDescriptions),
|
||||
"Expected %d, got %d",
|
||||
config.QuantityAdminScopes,
|
||||
len(scopes),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -25,10 +25,6 @@ const (
|
|||
PhotoDiskPath = "./web/static/photos"
|
||||
)
|
||||
|
||||
// PhotoURLRegexp describes an image path under "/static/photos" that can be parsed from Markdown or HTML input.
|
||||
// It is used by e.g. the ReSignURLs function - if you move image URLs to a CDN this may need updating.
|
||||
var PhotoURLRegexp = regexp.MustCompile(`(?:['"])(/static/photos/[^'"\s?]+(?:\?[^'"\s]*)?)(?:['"]|[^'"\s]*)`)
|
||||
|
||||
// Security
|
||||
const (
|
||||
BcryptCost = 14
|
||||
|
@ -42,11 +38,6 @@ const (
|
|||
|
||||
TwoFactorBackupCodeCount = 12
|
||||
TwoFactorBackupCodeLength = 8 // characters a-z0-9
|
||||
|
||||
// Signed URLs for static photo authentication.
|
||||
SignedPhotoJWTExpires = 30 * time.Second // Regular, per-user, short window
|
||||
SignedPublicAvatarJWTExpires = 7 * 24 * time.Hour // Widely public, e.g. chat room
|
||||
SignedPublicAvatarUsername = "@" // JWT 'username' for widely public JWT
|
||||
)
|
||||
|
||||
// Authentication
|
||||
|
@ -60,11 +51,6 @@ const (
|
|||
ChangeEmailRedisKey = "change-email/%s"
|
||||
SignupTokenExpires = 24 * time.Hour // used for all tokens so far
|
||||
|
||||
// How to rate limit same types of emails being delivered, e.g.
|
||||
// signups, cert approvals (double post), etc.
|
||||
EmailDebounceDefault = 24 * time.Hour // default debounce per type of email
|
||||
EmailDebounceResetPassword = 4 * time.Hour // "forgot password" emails debounce
|
||||
|
||||
// Rate limits
|
||||
RateLimitRedisKey = "rate-limit/%s/%s" // namespace, id
|
||||
LoginRateLimitWindow = 1 * time.Hour
|
||||
|
@ -92,9 +78,6 @@ const (
|
|||
|
||||
// Chat room status refresh interval.
|
||||
ChatStatusRefreshInterval = 30 * time.Second
|
||||
|
||||
// Cache TTL for the demographics page.
|
||||
DemographicsCacheTTL = time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -130,12 +113,6 @@ const (
|
|||
// pictures can be posted per day.
|
||||
SiteGalleryRateLimitMax = 5
|
||||
SiteGalleryRateLimitInterval = 24 * time.Hour
|
||||
|
||||
// Only ++ the Views count per user per photo within a small
|
||||
// window of time - if a user keeps reloading the same photo
|
||||
// rapidly it does not increment the view counter more.
|
||||
PhotoViewDebounceRedisKey = "debounce-view/user=%d/photoid=%d"
|
||||
PhotoViewDebounceCooldown = 1 * time.Hour
|
||||
)
|
||||
|
||||
// Forum settings
|
||||
|
|
|
@ -79,61 +79,10 @@ var (
|
|||
"dm_privacy",
|
||||
"blur_explicit",
|
||||
"site_gallery_default", // default view on site gallery (friends-only or all certified?)
|
||||
"chat_moderation_rules",
|
||||
}
|
||||
|
||||
// Website theme color hue choices.
|
||||
WebsiteThemeHueChoices = []OptGroup{
|
||||
{
|
||||
Header: "Custom Themes",
|
||||
Options: []Option{
|
||||
{
|
||||
Label: "Default (no added color; classic nonshy theme)",
|
||||
Value: "",
|
||||
},
|
||||
{
|
||||
Label: "nonshy blue & pink",
|
||||
Value: "blue-pink",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Just a Splash of Color",
|
||||
Options: []Option{
|
||||
{
|
||||
Label: "Burnt red",
|
||||
Value: "red",
|
||||
},
|
||||
{
|
||||
Label: "Harvest orange",
|
||||
Value: "orange",
|
||||
},
|
||||
{
|
||||
Label: "Golden yellow",
|
||||
Value: "yellow",
|
||||
},
|
||||
{
|
||||
Label: "Leafy green",
|
||||
Value: "green",
|
||||
},
|
||||
{
|
||||
Label: "Cool blue",
|
||||
Value: "blue",
|
||||
},
|
||||
{
|
||||
Label: "Pretty in pink",
|
||||
Value: "pink",
|
||||
},
|
||||
{
|
||||
Label: "Royal purple",
|
||||
Value: "purple",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Choices for the Contact Us subject
|
||||
ContactUsChoices = []OptGroup{
|
||||
ContactUsChoices = []ContactUs{
|
||||
{
|
||||
Header: "Website Feedback",
|
||||
Options: []Option{
|
||||
|
@ -165,83 +114,15 @@ var (
|
|||
"Anything Goes",
|
||||
}
|
||||
|
||||
// Forum Poll expiration options.
|
||||
PollExpires = []Option{
|
||||
{
|
||||
Label: "Never",
|
||||
Value: "0",
|
||||
},
|
||||
{
|
||||
Label: "1 Day",
|
||||
Value: "1",
|
||||
},
|
||||
{
|
||||
Label: "2 Days",
|
||||
Value: "2",
|
||||
},
|
||||
{
|
||||
Label: "3 Days",
|
||||
Value: "3",
|
||||
},
|
||||
{
|
||||
Label: "4 Days",
|
||||
Value: "4",
|
||||
},
|
||||
{
|
||||
Label: "5 Days",
|
||||
Value: "5",
|
||||
},
|
||||
{
|
||||
Label: "6 Days",
|
||||
Value: "6",
|
||||
},
|
||||
{
|
||||
Label: "7 Days",
|
||||
Value: "7",
|
||||
},
|
||||
{
|
||||
Label: "2 Weeks",
|
||||
Value: "14",
|
||||
},
|
||||
{
|
||||
Label: "1 Month (30 days)",
|
||||
Value: "30",
|
||||
},
|
||||
}
|
||||
|
||||
// Keywords that appear in a DM that make it likely spam.
|
||||
DirectMessageSpamKeywords = []*regexp.Regexp{
|
||||
regexp.MustCompile(`\b(telegram|whats\s*app|signal|kik|session)\b`),
|
||||
regexp.MustCompile(`https?://(t.me|join.skype.com|zoom.us|whereby.com|meet.jit.si|wa.me)`),
|
||||
}
|
||||
|
||||
// Chat Moderation Rules.
|
||||
ChatModerationRules = []ChecklistOption{
|
||||
{
|
||||
Value: "redcam",
|
||||
Label: "Red camera",
|
||||
Help: "The user's camera is forced to 'explicit' when they are broadcasting.",
|
||||
},
|
||||
{
|
||||
Value: "nobroadcast",
|
||||
Label: "No broadcast",
|
||||
Help: "The user can not broadcast their webcam, but may still watch other peoples' webcams.",
|
||||
},
|
||||
{
|
||||
Value: "novideo",
|
||||
Label: "No webcam privileges ('Shy Accounts')",
|
||||
Help: "The user can not broadcast or watch any webcam. Note: this option supercedes all other video-related rules.",
|
||||
},
|
||||
{
|
||||
Value: "noimage",
|
||||
Label: "No image sharing privileges ('Shy Accounts')",
|
||||
Help: "The user can not share or see any image shared on chat.",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// OptGroup choices for the subject drop-down.
|
||||
type OptGroup struct {
|
||||
// ContactUs choices for the subject drop-down.
|
||||
type ContactUs struct {
|
||||
Header string
|
||||
Options []Option
|
||||
}
|
||||
|
@ -252,13 +133,6 @@ type Option struct {
|
|||
Label string
|
||||
}
|
||||
|
||||
// ChecklistOption for checkbox-lists.
|
||||
type ChecklistOption struct {
|
||||
Value string
|
||||
Label string
|
||||
Help string
|
||||
}
|
||||
|
||||
// NotificationOptout field values (stored in user ProfileField table)
|
||||
const (
|
||||
NotificationOptOutFriendPhotos = "notif_optout_friends_photos"
|
||||
|
|
|
@ -12,7 +12,6 @@ var (
|
|||
PageSizeMemberSearch = 60
|
||||
PageSizeFriends = 12
|
||||
PageSizeBlockList = 12
|
||||
PageSizeMuteList = PageSizeBlockList
|
||||
PageSizePrivatePhotoGrantees = 12
|
||||
PageSizeAdminCertification = 20
|
||||
PageSizeAdminFeedback = 20
|
||||
|
|
|
@ -15,7 +15,7 @@ import (
|
|||
|
||||
// Version of the config format - when new fields are added, it will attempt
|
||||
// to write the settings.toml to disk so new defaults populate.
|
||||
var currentVersion = 5
|
||||
var currentVersion = 4
|
||||
|
||||
// Current loaded settings.json
|
||||
var Current = DefaultVariable()
|
||||
|
@ -32,7 +32,6 @@ type Variable struct {
|
|||
BareRTC BareRTC
|
||||
Maintenance Maintenance
|
||||
Encryption Encryption
|
||||
SignedPhoto SignedPhoto
|
||||
WebPush WebPush
|
||||
Turnstile Turnstile
|
||||
UseXForwardedFor bool
|
||||
|
@ -127,12 +126,6 @@ func LoadSettings() {
|
|||
writeSettings = true
|
||||
}
|
||||
|
||||
// Initialize JWT token for SignedPhoto feature.
|
||||
if Current.SignedPhoto.JWTSecret == "" {
|
||||
Current.SignedPhoto.JWTSecret = uuid.New().String()
|
||||
writeSettings = true
|
||||
}
|
||||
|
||||
// Have we added new config fields? Save the settings.json.
|
||||
if Current.Version != currentVersion || writeSettings {
|
||||
log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.")
|
||||
|
@ -203,12 +196,6 @@ type Encryption struct {
|
|||
ColdStorageRSAPublicKey []byte
|
||||
}
|
||||
|
||||
// SignedPhoto settings.
|
||||
type SignedPhoto struct {
|
||||
Enabled bool
|
||||
JWTSecret string
|
||||
}
|
||||
|
||||
// WebPush settings.
|
||||
type WebPush struct {
|
||||
VAPIDPublicKey string
|
||||
|
|
|
@ -50,7 +50,7 @@ func Dashboard() http.HandlerFunc {
|
|||
pager := &models.Pagination{
|
||||
Page: 1,
|
||||
PerPage: config.PageSizeDashboardNotifications,
|
||||
Sort: "read, created_at desc",
|
||||
Sort: "created_at desc",
|
||||
}
|
||||
pager.ParsePage(r)
|
||||
notifs, err := models.PaginateNotifications(currentUser, nf, pager)
|
||||
|
|
|
@ -3,9 +3,7 @@ package account
|
|||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/middleware"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
|
@ -40,8 +38,7 @@ func Profile() http.HandlerFunc {
|
|||
}
|
||||
|
||||
vars := map[string]interface{}{
|
||||
"User": user,
|
||||
"IsExternalView": true,
|
||||
"User": user,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
@ -73,9 +70,9 @@ func Profile() http.HandlerFunc {
|
|||
// Inject relationship booleans for profile picture display.
|
||||
models.SetUserRelationships(currentUser, []*models.User{user})
|
||||
|
||||
// Admin user (photo moderator) can always see the profile pic - but only on this page.
|
||||
// Other avatar displays will show the yellow or pink shy.png if the admin is not friends or not granted.
|
||||
if currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||
// Admin user can always see the profile pic - but only on this page. Other avatar displays
|
||||
// will show the yellow or pink shy.png if the admin is not friends or not granted.
|
||||
if currentUser.IsAdmin {
|
||||
user.UserRelationship.IsFriend = true
|
||||
user.UserRelationship.IsPrivateGranted = true
|
||||
}
|
||||
|
@ -104,14 +101,6 @@ func Profile() http.HandlerFunc {
|
|||
log.Error("WhoLikes(user %d): %s", user.ID, err)
|
||||
}
|
||||
|
||||
// Chat Moderation Rule: count of rules applied to the user, for admin view.
|
||||
var chatModerationRules int
|
||||
if currentUser.HasAdminScope(config.ScopeChatModerator) {
|
||||
if rules := user.GetProfileField("chat_moderation_rules"); len(rules) > 0 {
|
||||
chatModerationRules = len(strings.Split(rules, ","))
|
||||
}
|
||||
}
|
||||
|
||||
vars := map[string]interface{}{
|
||||
"User": user,
|
||||
"LikeMap": likeMap,
|
||||
|
@ -127,9 +116,6 @@ func Profile() http.HandlerFunc {
|
|||
"LikeRemainder": likeRemainder,
|
||||
"LikeTableName": "users",
|
||||
"LikeTableID": user.ID,
|
||||
|
||||
// Admin numbers.
|
||||
"NumChatModerationRules": chatModerationRules,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
|
|
|
@ -135,25 +135,17 @@ func ForgotPassword() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Email them their reset link -- if not banned.
|
||||
if !user.IsBanned() {
|
||||
if err := mail.LockSending("reset_password", user.Email, config.EmailDebounceResetPassword); err == nil {
|
||||
if err := mail.Send(mail.Message{
|
||||
To: user.Email,
|
||||
Subject: "Reset your forgotten password",
|
||||
Template: "email/reset_password.html",
|
||||
Data: map[string]interface{}{
|
||||
"Username": user.Username,
|
||||
"URL": config.Current.BaseURL + "/forgot-password?token=" + token.Token,
|
||||
},
|
||||
}); err != nil {
|
||||
session.FlashError(w, r, "Error sending an email: %s", err)
|
||||
}
|
||||
} else {
|
||||
log.Error("LockSending: reset_password e-mail is not sent to %s: one was sent recently", user.Email)
|
||||
}
|
||||
} else {
|
||||
log.Error("Do not send 'forgot password' e-mail to %s: user is banned", user.Email)
|
||||
// Email them their reset link.
|
||||
if err := mail.Send(mail.Message{
|
||||
To: user.Email,
|
||||
Subject: "Reset your forgotten password",
|
||||
Template: "email/reset_password.html",
|
||||
Data: map[string]interface{}{
|
||||
"Username": user.Username,
|
||||
"URL": config.Current.BaseURL + "/forgot-password?token=" + token.Token,
|
||||
},
|
||||
}); err != nil {
|
||||
session.FlashError(w, r, "Error sending an email: %s", err)
|
||||
}
|
||||
|
||||
// Success message and redirect away.
|
||||
|
|
|
@ -6,7 +6,6 @@ import (
|
|||
"strconv"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/controller/chat"
|
||||
"code.nonshy.com/nonshy/website/pkg/geoip"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
|
@ -45,7 +44,6 @@ func Search() http.HandlerFunc {
|
|||
hereFor = r.FormValue("here_for")
|
||||
friendSearch = r.FormValue("friends") == "true"
|
||||
likedSearch = r.FormValue("liked") == "true"
|
||||
onChatSearch = r.FormValue("on_chat") == "true"
|
||||
sort = r.FormValue("sort")
|
||||
sortOK bool
|
||||
)
|
||||
|
@ -151,17 +149,6 @@ func Search() http.HandlerFunc {
|
|||
certifiedOnly = true
|
||||
}
|
||||
|
||||
// Are we filtering for "On Chat?"
|
||||
var inUsername = []string{}
|
||||
if onChatSearch {
|
||||
stats := chat.FilteredChatStatistics(currentUser)
|
||||
inUsername = stats.Usernames
|
||||
if len(inUsername) == 0 {
|
||||
session.FlashError(w, r, "Notice: you wanted to filter by people currently on the chat room, but nobody is on chat at this time.")
|
||||
inUsername = []string{"@"}
|
||||
}
|
||||
}
|
||||
|
||||
pager := &models.Pagination{
|
||||
PerPage: config.PageSizeMemberSearch,
|
||||
Sort: sort,
|
||||
|
@ -170,7 +157,6 @@ func Search() http.HandlerFunc {
|
|||
|
||||
users, err := models.SearchUsers(currentUser, &models.UserSearch{
|
||||
Username: username,
|
||||
InUsername: inUsername,
|
||||
Gender: gender,
|
||||
Orientation: orientation,
|
||||
MaritalStatus: maritalStatus,
|
||||
|
@ -227,7 +213,6 @@ func Search() http.HandlerFunc {
|
|||
"AgeMax": ageMax,
|
||||
"FriendSearch": friendSearch,
|
||||
"LikedSearch": likedSearch,
|
||||
"OnChatSearch": onChatSearch,
|
||||
"Sort": sort,
|
||||
|
||||
// Restricted Search errors.
|
||||
|
|
|
@ -42,8 +42,7 @@ func Settings() http.HandlerFunc {
|
|||
var reHexColor = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := map[string]interface{}{
|
||||
"Enum": config.ProfileEnums,
|
||||
"WebsiteThemeHueChoices": config.WebsiteThemeHueChoices,
|
||||
"Enum": config.ProfileEnums,
|
||||
}
|
||||
|
||||
// Load the current user in case of updates.
|
||||
|
@ -63,10 +62,6 @@ func Settings() http.HandlerFunc {
|
|||
|
||||
// Are we POSTing?
|
||||
if r.Method == http.MethodPost {
|
||||
|
||||
// Will they BECOME a Shy Account with this change?
|
||||
var wasShy = user.IsShy()
|
||||
|
||||
intent := r.PostFormValue("intent")
|
||||
switch intent {
|
||||
case "profile":
|
||||
|
@ -187,27 +182,12 @@ func Settings() http.HandlerFunc {
|
|||
for _, field := range []string{
|
||||
"hero-text-dark",
|
||||
"card-lightness",
|
||||
"website-theme", // light, dark, auto
|
||||
"website-theme",
|
||||
} {
|
||||
value := r.PostFormValue(field)
|
||||
user.SetProfileField(field, value)
|
||||
}
|
||||
|
||||
// Website theme color: constrain to available options.
|
||||
for _, field := range []struct {
|
||||
Name string
|
||||
Options []config.OptGroup
|
||||
}{
|
||||
{"website-theme-hue", config.WebsiteThemeHueChoices},
|
||||
} {
|
||||
value := utility.StringInOptGroup(
|
||||
r.PostFormValue(field.Name),
|
||||
field.Options,
|
||||
"",
|
||||
)
|
||||
user.SetProfileField(field.Name, value)
|
||||
}
|
||||
|
||||
if err := user.Save(); err != nil {
|
||||
session.FlashError(w, r, "Failed to save user to database: %s", err)
|
||||
}
|
||||
|
@ -240,7 +220,6 @@ func Settings() http.HandlerFunc {
|
|||
var (
|
||||
visibility = models.UserVisibility(r.PostFormValue("visibility"))
|
||||
dmPrivacy = r.PostFormValue("dm_privacy")
|
||||
ppPrivacy = r.PostFormValue("private_photo_gate")
|
||||
)
|
||||
|
||||
user.Visibility = models.UserVisibilityPublic
|
||||
|
@ -253,7 +232,6 @@ func Settings() http.HandlerFunc {
|
|||
|
||||
// Set profile field prefs.
|
||||
user.SetProfileField("dm_privacy", dmPrivacy)
|
||||
user.SetProfileField("private_photo_gate", ppPrivacy)
|
||||
|
||||
if err := user.Save(); err != nil {
|
||||
session.FlashError(w, r, "Failed to save user to database: %s", err)
|
||||
|
@ -494,10 +472,8 @@ func Settings() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Maybe kick them from the chat room if they had become a Shy Account.
|
||||
if !wasShy && user.IsShy() {
|
||||
if _, err := chat.MaybeDisconnectUser(user); err != nil {
|
||||
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
|
||||
}
|
||||
if _, err := chat.MaybeDisconnectUser(user); err != nil {
|
||||
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
|
||||
}
|
||||
|
||||
templates.Redirect(w, r.URL.Path+hashtag+".")
|
||||
|
|
|
@ -139,29 +139,24 @@ func Signup() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Already an account?
|
||||
if user, err := models.FindUser(email); err == nil {
|
||||
if _, err := models.FindUser(email); err == nil {
|
||||
// We don't want to admit that the email already is registered, so send an email to the
|
||||
// address in case the user legitimately forgot, but flash the regular success message.
|
||||
if user.IsBanned() {
|
||||
log.Error("Do not send signup e-mail to %s: user is banned", email)
|
||||
} else {
|
||||
if err := mail.LockSending("signup", email, config.EmailDebounceDefault); err == nil {
|
||||
err := mail.Send(mail.Message{
|
||||
To: email,
|
||||
Subject: "You already have a nonshy account",
|
||||
Template: "email/already_signed_up.html",
|
||||
Data: map[string]interface{}{
|
||||
"Title": config.Title,
|
||||
"URL": config.Current.BaseURL + "/forgot-password",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Error sending an email: %s", err)
|
||||
}
|
||||
|
||||
} else {
|
||||
log.Error("LockSending: signup e-mail is not sent to %s: one was sent recently", email)
|
||||
if err := mail.LockSending("signup", email, config.SignupTokenExpires); err == nil {
|
||||
err := mail.Send(mail.Message{
|
||||
To: email,
|
||||
Subject: "You already have a nonshy account",
|
||||
Template: "email/already_signed_up.html",
|
||||
Data: map[string]interface{}{
|
||||
"Title": config.Title,
|
||||
"URL": config.Current.BaseURL + "/forgot-password",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Error sending an email: %s", err)
|
||||
}
|
||||
} else {
|
||||
log.Error("LockSending: signup e-mail is not sent to %s: one was sent recently", email)
|
||||
}
|
||||
|
||||
session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email)
|
||||
|
@ -273,11 +268,6 @@ func Signup() http.HandlerFunc {
|
|||
user.Birthdate = birthdate
|
||||
user.Save()
|
||||
|
||||
// Restore their block lists if this user has deleted their account before.
|
||||
if err := models.RestoreDeletedUserMemory(user); err != nil {
|
||||
log.Error("RestoreDeletedUserMemory(%s): %s", user.Username, err)
|
||||
}
|
||||
|
||||
// Log in the user and send them to their dashboard.
|
||||
session.LoginUser(w, r, user)
|
||||
templates.Redirect(w, "/me")
|
||||
|
|
|
@ -18,10 +18,7 @@ func UserNotes() http.HandlerFunc {
|
|||
tmpl := templates.Must("account/user_notes.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse the username out of the URL parameters.
|
||||
var (
|
||||
username = r.PathValue("username")
|
||||
show = r.FormValue("show") // admin feedback filter
|
||||
)
|
||||
var username = r.PathValue("username")
|
||||
|
||||
// Find this user.
|
||||
user, err := models.FindUser(username)
|
||||
|
@ -111,7 +108,7 @@ func UserNotes() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Paginate feedback & reports.
|
||||
if fb, err := models.PaginateFeedbackAboutUser(user, show, fbPager); err != nil {
|
||||
if fb, err := models.PaginateFeedbackAboutUser(user, fbPager); err != nil {
|
||||
session.FlashError(w, r, "Paginating feedback on this user: %s", err)
|
||||
} else {
|
||||
feedback = fb
|
||||
|
@ -144,7 +141,6 @@ func UserNotes() http.HandlerFunc {
|
|||
"MyNote": myNote,
|
||||
|
||||
// Admin concerns.
|
||||
"Show": show,
|
||||
"Feedback": feedback,
|
||||
"FeedbackPager": fbPager,
|
||||
"OtherNotes": otherNotes,
|
||||
|
|
|
@ -14,13 +14,6 @@ import (
|
|||
// Feedback controller (/admin/feedback)
|
||||
func Feedback() http.HandlerFunc {
|
||||
tmpl := templates.Must("admin/feedback.html")
|
||||
|
||||
// Whitelist for ordering options.
|
||||
var sortWhitelist = []string{
|
||||
"created_at desc",
|
||||
"created_at asc",
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Query params.
|
||||
var (
|
||||
|
@ -30,26 +23,8 @@ func Feedback() http.HandlerFunc {
|
|||
profile = r.FormValue("profile") == "true" // visit associated user profile
|
||||
verdict = r.FormValue("verdict")
|
||||
fb *models.Feedback
|
||||
|
||||
// Search filters.
|
||||
searchQuery = r.FormValue("q")
|
||||
search = models.ParseSearchString(searchQuery)
|
||||
subject = r.FormValue("subject")
|
||||
sort = r.FormValue("sort")
|
||||
sortOK bool
|
||||
)
|
||||
|
||||
// Sort options.
|
||||
for _, v := range sortWhitelist {
|
||||
if sort == v {
|
||||
sortOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !sortOK {
|
||||
sort = sortWhitelist[0]
|
||||
}
|
||||
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't get your current user: %s", err)
|
||||
|
@ -69,15 +44,6 @@ func Feedback() http.HandlerFunc {
|
|||
|
||||
// Are we visiting a linked resource (via TableID)?
|
||||
if fb != nil && fb.TableID > 0 && visit {
|
||||
// New (Oct 17 '24): feedbacks may carry an AboutUserID, e.g. for photos in case the reported
|
||||
// photo is removed then the associated owner of the photo is still carried in the report.
|
||||
var aboutUser *models.User
|
||||
if fb.AboutUserID > 0 {
|
||||
if user, err := models.GetUser(fb.AboutUserID); err == nil {
|
||||
aboutUser = user
|
||||
}
|
||||
}
|
||||
|
||||
switch fb.TableName {
|
||||
case "users":
|
||||
user, err := models.GetUser(fb.TableID)
|
||||
|
@ -90,29 +56,15 @@ func Feedback() http.HandlerFunc {
|
|||
case "photos":
|
||||
pic, err := models.GetPhoto(fb.TableID)
|
||||
if err != nil {
|
||||
// If there was an About User, visit their profile page instead.
|
||||
if aboutUser != nil {
|
||||
session.FlashError(w, r, "The photo #%d was deleted, visiting the owner's profile page instead.", fb.TableID)
|
||||
templates.Redirect(w, "/u/"+aboutUser.Username)
|
||||
return
|
||||
}
|
||||
|
||||
session.FlashError(w, r, "Couldn't get photo %d: %s", fb.TableID, err)
|
||||
} else {
|
||||
// Going to the user's profile page?
|
||||
if profile {
|
||||
|
||||
// Going forward: the aboutUser will be populated, this is for legacy reports.
|
||||
if aboutUser == nil {
|
||||
if user, err := models.GetUser(pic.UserID); err == nil {
|
||||
aboutUser = user
|
||||
} else {
|
||||
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if aboutUser != nil {
|
||||
templates.Redirect(w, "/u/"+aboutUser.Username)
|
||||
user, err := models.GetUser(pic.UserID)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
|
||||
} else {
|
||||
templates.Redirect(w, "/u/"+user.Username)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
@ -137,9 +89,19 @@ func Feedback() http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
case "comments":
|
||||
// Redirect to the comment redirector.
|
||||
templates.Redirect(w, fmt.Sprintf("/go/comment?id=%d", fb.TableID))
|
||||
return
|
||||
// Get this comment.
|
||||
comment, err := models.GetComment(fb.TableID)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't get comment ID %d: %s", fb.TableID, err)
|
||||
} else {
|
||||
// What was the comment on?
|
||||
switch comment.TableName {
|
||||
case "threads":
|
||||
// Visit the thread.
|
||||
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", comment.TableID))
|
||||
return
|
||||
}
|
||||
}
|
||||
case "forums":
|
||||
// Get this forum.
|
||||
forum, err := models.GetForum(fb.TableID)
|
||||
|
@ -187,51 +149,31 @@ func Feedback() http.HandlerFunc {
|
|||
pager := &models.Pagination{
|
||||
Page: 1,
|
||||
PerPage: config.PageSizeAdminFeedback,
|
||||
Sort: sort,
|
||||
Sort: "updated_at desc",
|
||||
}
|
||||
pager.ParsePage(r)
|
||||
page, err := models.PaginateFeedback(acknowledged, intent, subject, search, pager)
|
||||
page, err := models.PaginateFeedback(acknowledged, intent, pager)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't load feedback from DB: %s", err)
|
||||
}
|
||||
|
||||
// Map user IDs.
|
||||
var (
|
||||
userIDs = []uint64{}
|
||||
photoIDs = []uint64{}
|
||||
)
|
||||
var userIDs = []uint64{}
|
||||
for _, p := range page {
|
||||
if p.UserID > 0 {
|
||||
userIDs = append(userIDs, p.UserID)
|
||||
}
|
||||
|
||||
if p.TableName == "photos" && p.TableID > 0 {
|
||||
photoIDs = append(photoIDs, p.TableID)
|
||||
}
|
||||
}
|
||||
userMap, err := models.MapUsers(currentUser, userIDs)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't map user IDs: %s", err)
|
||||
}
|
||||
|
||||
// Map photo IDs.
|
||||
photoMap, err := models.MapPhotos(photoIDs)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't map photo IDs: %s", err)
|
||||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
// Filter settings.
|
||||
"DistinctSubjects": models.DistinctFeedbackSubjects(),
|
||||
"SearchTerm": searchQuery,
|
||||
"Subject": subject,
|
||||
"Sort": sort,
|
||||
|
||||
"Intent": intent,
|
||||
"Acknowledged": acknowledged,
|
||||
"Feedback": page,
|
||||
"UserMap": userMap,
|
||||
"PhotoMap": photoMap,
|
||||
"Pager": pager,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
|
|
|
@ -52,7 +52,6 @@ func MarkPhotoExplicit() http.HandlerFunc {
|
|||
}
|
||||
|
||||
photo.Explicit = true
|
||||
photo.Flagged = true
|
||||
if err := photo.Save(); err != nil {
|
||||
session.FlashError(w, r, "Couldn't save photo: %s", err)
|
||||
} else {
|
||||
|
@ -118,60 +117,11 @@ func UserActions() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Get their block lists.
|
||||
insights, err := models.GetBlocklistInsights(user)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Error getting blocklist insights: %s", err)
|
||||
}
|
||||
vars["BlocklistInsights"] = insights
|
||||
|
||||
// Also surface counts of admin blocks.
|
||||
count, total := models.CountBlockedAdminUsers(user)
|
||||
vars["AdminBlockCount"] = count
|
||||
vars["AdminBlockTotal"] = total
|
||||
case "chat.rules":
|
||||
// Chat Moderation Rules.
|
||||
if !currentUser.HasAdminScope(config.ScopeChatModerator) {
|
||||
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeChatModerator)
|
||||
templates.Redirect(w, "/admin")
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
// Rules list for the change log.
|
||||
var newRules = "(none)"
|
||||
if rule, ok := r.PostForm["rules"]; ok && len(rule) > 0 {
|
||||
newRules = strings.Join(rule, ",")
|
||||
user.SetProfileField("chat_moderation_rules", newRules)
|
||||
if err := user.Save(); err != nil {
|
||||
session.FlashError(w, r, "Error saving the user's chat rules: %s", err)
|
||||
} else {
|
||||
session.Flash(w, r, "Chat moderation rules have been updated!")
|
||||
}
|
||||
} else {
|
||||
user.DeleteProfileField("chat_moderation_rules")
|
||||
session.Flash(w, r, "All chat moderation rules have been cleared for user: %s", user.Username)
|
||||
}
|
||||
|
||||
templates.Redirect(w, "/u/"+user.Username)
|
||||
|
||||
// Log the new rules to the changelog.
|
||||
models.LogEvent(
|
||||
user,
|
||||
currentUser,
|
||||
"updated",
|
||||
"chat.rules",
|
||||
user.ID,
|
||||
fmt.Sprintf(
|
||||
"An admin has updated the chat moderation rules for this user.\n\n"+
|
||||
"The update rules are: %s",
|
||||
newRules,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
vars["ChatModerationRules"] = config.ChatModerationRules
|
||||
case "essays":
|
||||
// Edit their profile essays easily.
|
||||
if !currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||
|
|
|
@ -79,9 +79,6 @@ func Report() http.HandlerFunc {
|
|||
|
||||
log.Debug("Got chat report: %+v", report)
|
||||
|
||||
// Make a clickable profile link for the channel ID (other user).
|
||||
otherUsername := strings.TrimPrefix(report.Channel, "@")
|
||||
|
||||
// Create an admin Feedback model.
|
||||
fb := &models.Feedback{
|
||||
Intent: "report",
|
||||
|
@ -90,7 +87,7 @@ func Report() http.HandlerFunc {
|
|||
"A message was reported on the chat room!\n\n"+
|
||||
"* From username: [%s](/u/%s)\n"+
|
||||
"* About username: [%s](/u/%s)\n"+
|
||||
"* Channel: [**%s**](/u/%s)\n"+
|
||||
"* Channel: **%s**\n"+
|
||||
"* Timestamp: %s\n"+
|
||||
"* Classification: %s\n"+
|
||||
"* User comment: %s\n\n"+
|
||||
|
@ -98,7 +95,7 @@ func Report() http.HandlerFunc {
|
|||
"The reported message on chat was:\n\n%s",
|
||||
report.FromUsername, report.FromUsername,
|
||||
report.AboutUsername, report.AboutUsername,
|
||||
report.Channel, otherUsername,
|
||||
report.Channel,
|
||||
report.Timestamp,
|
||||
report.Reason,
|
||||
report.Comment,
|
||||
|
@ -119,7 +116,6 @@ func Report() http.HandlerFunc {
|
|||
if err == nil {
|
||||
fb.TableName = "users"
|
||||
fb.TableID = targetUser.ID
|
||||
fb.AboutUserID = targetUser.ID
|
||||
} else {
|
||||
log.Error("BareRTC Chat Feedback: couldn't find user ID for AboutUsername=%s: %s", report.AboutUsername, err)
|
||||
}
|
||||
|
|
|
@ -8,7 +8,6 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/photo"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
)
|
||||
|
||||
|
@ -99,18 +98,26 @@ func Likes() http.HandlerFunc {
|
|||
if user, err := models.GetUser(photo.UserID); err == nil {
|
||||
// Safety check: if the current user should not see this picture, they can not "Like" it.
|
||||
// Example: you unfriended them but they still had the image on their old browser page.
|
||||
if ok, _ := photo.ShouldBeSeenBy(currentUser); !ok {
|
||||
var unallowed bool
|
||||
if currentUser.ID != user.ID {
|
||||
if (photo.Visibility == models.PhotoFriends && !models.AreFriends(user.ID, currentUser.ID)) ||
|
||||
(photo.Visibility == models.PhotoPrivate && !models.IsPrivateUnlocked(user.ID, currentUser.ID)) {
|
||||
unallowed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Blocking safety check: if either user blocks the other, liking is not allowed.
|
||||
if models.IsBlocking(currentUser.ID, user.ID) {
|
||||
unallowed = true
|
||||
}
|
||||
|
||||
if unallowed {
|
||||
SendJSON(w, http.StatusForbidden, Response{
|
||||
Error: "You are not allowed to like that photo.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mark this photo as 'viewed' if it received a like.
|
||||
// Example: on a gallery view the photo is only 'viewed' if interacted with (lightbox),
|
||||
// going straight for the 'Like' button should count as well.
|
||||
photo.View(currentUser)
|
||||
|
||||
targetUser = user
|
||||
}
|
||||
} else {
|
||||
|
@ -197,13 +204,6 @@ func Likes() http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// Refresh cached like counts.
|
||||
if req.TableName == "photos" {
|
||||
if err := models.UpdatePhotoCachedCounts(tableID); err != nil {
|
||||
log.Error("UpdatePhotoCachedCount(%d): %s", tableID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Send success response.
|
||||
SendJSON(w, http.StatusOK, Response{
|
||||
OK: true,
|
||||
|
@ -286,7 +286,7 @@ func WhoLikes() http.HandlerFunc {
|
|||
for _, user := range users {
|
||||
result = append(result, Liker{
|
||||
Username: user.Username,
|
||||
Avatar: photo.VisibleAvatarURL(user, currentUser),
|
||||
Avatar: user.VisibleAvatarURL(currentUser),
|
||||
Relationship: user.UserRelationship,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -83,7 +83,6 @@ func MarkPhotoExplicit() http.HandlerFunc {
|
|||
}
|
||||
|
||||
photo.Explicit = true
|
||||
photo.Flagged = true
|
||||
if err := photo.Save(); err != nil {
|
||||
SendJSON(w, http.StatusBadRequest, Response{
|
||||
Error: fmt.Sprintf("Couldn't save the photo: %s", err),
|
||||
|
@ -91,23 +90,6 @@ func MarkPhotoExplicit() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Send the photo's owner a notification so they are aware.
|
||||
if owner, err := models.GetUser(photo.UserID); err == nil {
|
||||
notif := &models.Notification{
|
||||
UserID: owner.ID,
|
||||
AboutUser: *owner,
|
||||
Type: models.NotificationExplicitPhoto,
|
||||
TableName: "photos",
|
||||
TableID: photo.ID,
|
||||
Link: fmt.Sprintf("/photo/view?id=%d", photo.ID),
|
||||
}
|
||||
if err := models.CreateNotification(notif); err != nil {
|
||||
log.Error("Couldn't create Likes notification: %s", err)
|
||||
}
|
||||
} else {
|
||||
log.Error("MarkExplicit: getting photo owner (%d): %s", photo.UserID, err)
|
||||
}
|
||||
|
||||
// If a non-admin user has hit this API, log an admin report for visibility and
|
||||
// to keep a pulse on things (e.g. in case of abuse).
|
||||
if !currentUser.IsAdmin {
|
||||
|
|
|
@ -1,70 +0,0 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
)
|
||||
|
||||
// ViewPhoto API pings a view count on a photo, e.g. from the lightbox modal.
|
||||
func ViewPhoto() http.HandlerFunc {
|
||||
// Response JSON schema.
|
||||
type Response struct {
|
||||
OK bool `json:"OK"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Likes int64 `json:"likes"`
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Get the current user.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
SendJSON(w, http.StatusBadRequest, Response{
|
||||
Error: "Couldn't get current user!",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Photo ID from path parameter.
|
||||
var photoID uint64
|
||||
if id, err := strconv.Atoi(r.PathValue("photo_id")); err == nil && id > 0 {
|
||||
photoID = uint64(id)
|
||||
} else {
|
||||
SendJSON(w, http.StatusBadRequest, Response{
|
||||
Error: "Invalid photo ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Find this photo.
|
||||
photo, err := models.GetPhoto(photoID)
|
||||
if err != nil {
|
||||
SendJSON(w, http.StatusNotFound, Response{
|
||||
Error: "Photo Not Found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check permission to have seen this photo.
|
||||
if ok, err := photo.ShouldBeSeenBy(currentUser); !ok {
|
||||
log.Error("Photo %d can't be seen by %s: %s", photo.ID, currentUser.Username, err)
|
||||
SendJSON(w, http.StatusNotFound, Response{
|
||||
Error: "Photo Not Found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mark a view.
|
||||
if err := photo.View(currentUser); err != nil {
|
||||
log.Error("Update photo(%d) views: %s", photo.ID, err)
|
||||
}
|
||||
|
||||
// Send success response.
|
||||
SendJSON(w, http.StatusOK, Response{
|
||||
OK: true,
|
||||
})
|
||||
})
|
||||
}
|
|
@ -1,106 +0,0 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/encryption"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/photo"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
)
|
||||
|
||||
// PhotoSignAuth API protects paths like /static/photos/ to authenticated user requests only.
|
||||
func PhotoSignAuth() http.HandlerFunc {
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:",omitempty"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
logAndError := func(w http.ResponseWriter, m string, v ...interface{}) {
|
||||
log.Debug("ERROR PhotoSignAuth: "+m, v...)
|
||||
SendJSON(w, http.StatusForbidden, Response{
|
||||
Error: fmt.Sprintf(m, v...),
|
||||
})
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// We only protect the /static/photos subpath.
|
||||
// And check if the SignedPhoto feature is enabled and enforcing.
|
||||
var originalURI = r.Header.Get("X-Original-URI")
|
||||
if !config.Current.SignedPhoto.Enabled || !strings.HasPrefix(originalURI, config.PhotoWebPath) {
|
||||
SendJSON(w, http.StatusOK, Response{
|
||||
Success: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the base filename.
|
||||
var filename = strings.TrimPrefix(
|
||||
strings.SplitN(originalURI, config.PhotoWebPath, 2)[1],
|
||||
"/",
|
||||
)
|
||||
filename = strings.SplitN(filename, "?", 2)[0] // inner query string too
|
||||
|
||||
// Parse the JWT token parameter from the original URL.
|
||||
var token string
|
||||
if path, err := url.Parse(originalURI); err == nil {
|
||||
query := path.Query()
|
||||
token = query.Get("jwt")
|
||||
}
|
||||
|
||||
// The JWT token is required from here on out.
|
||||
if token == "" {
|
||||
logAndError(w, "JWT token is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we're logged in and who the current username is.
|
||||
var username string
|
||||
if currentUser, err := session.CurrentUser(r); err == nil {
|
||||
username = currentUser.Username
|
||||
}
|
||||
|
||||
// Validate the JWT token is correctly signed and not expired.
|
||||
claims, ok, err := encryption.ValidateClaims(
|
||||
token,
|
||||
[]byte(config.Current.SignedPhoto.JWTSecret),
|
||||
&photo.SignedPhotoClaims{},
|
||||
)
|
||||
if !ok || err != nil {
|
||||
logAndError(w, "When validating JWT claims: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the claims to get data to validate this request.
|
||||
c, ok := claims.(*photo.SignedPhotoClaims)
|
||||
if !ok {
|
||||
logAndError(w, "JWT claims were not the correct shape: %+v", claims)
|
||||
return
|
||||
}
|
||||
|
||||
// Was the signature for our username? (Skip if for Anyone)
|
||||
if !c.Anyone && c.Subject != username {
|
||||
logAndError(w, "That token did not belong to you")
|
||||
return
|
||||
}
|
||||
|
||||
// Is the file name correct?
|
||||
hash := photo.FilenameHash(filename)
|
||||
if hash != c.FilenameHash {
|
||||
logAndError(w, "Filename hash mismatch: fn=%s hash=%s jwt=%s", filename, hash, c.FilenameHash)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("PhotoSignAuth: JWT Signature OK! fn=%s u=%s anyone=%v expires=%+v", filename, c.Subject, c.Anyone, c.ExpiresAt)
|
||||
|
||||
SendJSON(w, http.StatusOK, Response{
|
||||
Success: true,
|
||||
Username: username,
|
||||
})
|
||||
})
|
||||
}
|
|
@ -112,35 +112,17 @@ func BlockUser() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// If the target user is an admin, log this to the admin reports page.
|
||||
if user.IsAdmin {
|
||||
// Is the target admin user unblockable?
|
||||
var (
|
||||
unblockable = user.HasAdminScope(config.ScopeUnblockable)
|
||||
footer string // qualifier for the admin report body
|
||||
)
|
||||
|
||||
// Add a footer to the report to indicate whether the block goes through.
|
||||
if unblockable {
|
||||
footer = "**Unblockable:** this admin can not be blocked, so the block was not added and the user was shown an error message."
|
||||
} else {
|
||||
footer = "**Notice:** This admin is not unblockable, so the block has been added successfully."
|
||||
}
|
||||
|
||||
// Also, include this user's current count of blocked admin users.
|
||||
count, total := models.CountBlockedAdminUsers(currentUser)
|
||||
footer += fmt.Sprintf("\n\nThis user now blocks %d of %d admin user(s) on this site.", count+1, total)
|
||||
|
||||
// Can't block admins who have the unblockable scope.
|
||||
if user.IsAdmin && user.HasAdminScope(config.ScopeUnblockable) {
|
||||
// For curiosity's sake, log a report.
|
||||
fb := &models.Feedback{
|
||||
Intent: "report",
|
||||
Subject: "A user tried to block an admin",
|
||||
Message: fmt.Sprintf(
|
||||
"A user has tried to block an admin user account!\n\n"+
|
||||
"* Username: %s\n* Tried to block: %s\n\n%s",
|
||||
"* Username: %s\n* Tried to block: %s",
|
||||
currentUser.Username,
|
||||
user.Username,
|
||||
footer,
|
||||
),
|
||||
UserID: currentUser.ID,
|
||||
TableName: "users",
|
||||
|
@ -150,12 +132,9 @@ func BlockUser() http.HandlerFunc {
|
|||
log.Error("Could not log feedback for user %s trying to block admin %s: %s", currentUser.Username, user.Username, err)
|
||||
}
|
||||
|
||||
// If the admin is unblockable, give the user an error message and return.
|
||||
if unblockable {
|
||||
session.FlashError(w, r, "You can not block site administrators.")
|
||||
templates.Redirect(w, "/u/"+username)
|
||||
return
|
||||
}
|
||||
session.FlashError(w, r, "You can not block site administrators.")
|
||||
templates.Redirect(w, "/u/"+username)
|
||||
return
|
||||
}
|
||||
|
||||
// Block the target user.
|
||||
|
|
|
@ -3,6 +3,7 @@ package chat
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
@ -10,7 +11,6 @@ import (
|
|||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/encryption"
|
||||
"code.nonshy.com/nonshy/website/pkg/geoip"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/middleware"
|
||||
|
@ -22,17 +22,16 @@ import (
|
|||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
// Claims are the JWT claims for the BareRTC chat room.
|
||||
// JWT claims.
|
||||
type Claims struct {
|
||||
// Custom claims.
|
||||
IsAdmin bool `json:"op,omitempty"`
|
||||
VIP bool `json:"vip,omitempty"`
|
||||
Avatar string `json:"img,omitempty"`
|
||||
ProfileURL string `json:"url,omitempty"`
|
||||
Nickname string `json:"nick,omitempty"`
|
||||
Emoji string `json:"emoji,omitempty"`
|
||||
Gender string `json:"gender,omitempty"`
|
||||
Rules []string `json:"rules,omitempty"`
|
||||
IsAdmin bool `json:"op,omitempty"`
|
||||
VIP bool `json:"vip,omitempty"`
|
||||
Avatar string `json:"img,omitempty"`
|
||||
ProfileURL string `json:"url,omitempty"`
|
||||
Nickname string `json:"nick,omitempty"`
|
||||
Emoji string `json:"emoji,omitempty"`
|
||||
Gender string `json:"gender,omitempty"`
|
||||
|
||||
// Standard claims. Notes:
|
||||
// subject = username
|
||||
|
@ -77,6 +76,16 @@ func Landing() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// If we are shy, block chat for now.
|
||||
if isShy {
|
||||
session.FlashError(w, r,
|
||||
"You have a Shy Account and are not allowed in the chat room at this time where our non-shy members may "+
|
||||
"be on camera.",
|
||||
)
|
||||
templates.Redirect(w, "/chat")
|
||||
return
|
||||
}
|
||||
|
||||
// Get our Chat JWT secret.
|
||||
var (
|
||||
secret = []byte(config.Current.BareRTC.JWTSecret)
|
||||
|
@ -89,7 +98,7 @@ func Landing() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Avatar URL - masked if non-public.
|
||||
avatar := photo.SignedPublicAvatarURL(currentUser.ProfilePhoto.CroppedFilename)
|
||||
avatar := photo.URLPath(currentUser.ProfilePhoto.CroppedFilename)
|
||||
switch currentUser.ProfilePhoto.Visibility {
|
||||
case models.PhotoPrivate:
|
||||
avatar = "/static/img/shy-private.png"
|
||||
|
@ -111,29 +120,25 @@ func Landing() http.HandlerFunc {
|
|||
emoji = "🍰 It's my birthday!"
|
||||
}
|
||||
|
||||
// Apply chat moderation rules.
|
||||
var rules = []string{}
|
||||
if isShy {
|
||||
// Shy account: no camera privileges.
|
||||
rules = []string{"novideo", "noimage"}
|
||||
} else if v := currentUser.GetProfileField("chat_moderation_rules"); len(v) > 0 {
|
||||
// Specific mod rules applied to the current user.
|
||||
rules = strings.Split(v, ",")
|
||||
}
|
||||
|
||||
// Create the JWT claims.
|
||||
claims := Claims{
|
||||
IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator),
|
||||
Avatar: avatar,
|
||||
ProfileURL: "/u/" + currentUser.Username,
|
||||
Nickname: currentUser.NameOrUsername(),
|
||||
Emoji: emoji,
|
||||
Gender: Gender(currentUser),
|
||||
VIP: isShy, // "shy accounts" use the "VIP" status for special icon in chat
|
||||
Rules: rules,
|
||||
RegisteredClaims: encryption.StandardClaims(currentUser.ID, currentUser.Username, time.Now().Add(5*time.Minute)),
|
||||
IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator),
|
||||
Avatar: avatar,
|
||||
ProfileURL: "/u/" + currentUser.Username,
|
||||
Nickname: currentUser.NameOrUsername(),
|
||||
Emoji: emoji,
|
||||
Gender: Gender(currentUser),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: config.Title,
|
||||
Subject: currentUser.Username,
|
||||
ID: fmt.Sprintf("%d", currentUser.ID),
|
||||
},
|
||||
}
|
||||
token, err := encryption.SignClaims(claims, []byte(config.Current.BareRTC.JWTSecret))
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
ss, err := token.SignedString(secret)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't sign you into the chat: %s", err)
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
|
@ -149,15 +154,8 @@ func Landing() http.HandlerFunc {
|
|||
// of time where they can exist in chat but change their name on the site.
|
||||
worker.GetChatStatistics().SetOnlineNow(currentUser.Username)
|
||||
|
||||
// Ping their chat login usage statistic.
|
||||
go func() {
|
||||
if err := models.LogDailyChatUser(currentUser); err != nil {
|
||||
log.Error("LogDailyChatUser(%s): error logging this user's chat statistic: %s", currentUser.Username, err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Redirect them to the chat room.
|
||||
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+token)
|
||||
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -121,14 +121,6 @@ func PostComment() http.HandlerFunc {
|
|||
// Log the change.
|
||||
models.LogDeleted(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, "Deleted a comment.", comment)
|
||||
}
|
||||
|
||||
// Refresh cached like counts.
|
||||
if tableName == "photos" {
|
||||
if err := models.UpdatePhotoCachedCounts(tableID); err != nil {
|
||||
log.Error("UpdatePhotoCachedCount(%d): %s", tableID, err)
|
||||
}
|
||||
}
|
||||
|
||||
templates.Redirect(w, fromURL)
|
||||
return
|
||||
}
|
||||
|
@ -182,13 +174,6 @@ func PostComment() http.HandlerFunc {
|
|||
session.Flash(w, r, "Comment added!")
|
||||
templates.Redirect(w, fromURL)
|
||||
|
||||
// Refresh cached comment counts.
|
||||
if tableName == "photos" {
|
||||
if err := models.UpdatePhotoCachedCounts(tableID); err != nil {
|
||||
log.Error("UpdatePhotoCachedCount(%d): %s", tableID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Log the change.
|
||||
models.LogCreated(currentUser, "comments", comment.ID, "Posted a new comment.\n\n---\n\n"+message)
|
||||
|
||||
|
|
|
@ -77,6 +77,7 @@ func AddEdit() http.HandlerFunc {
|
|||
// Sanity check admin-only settings -> default these to OFF.
|
||||
if !currentUser.HasAdminScope(config.ScopeForumAdmin) {
|
||||
isPrivileged = false
|
||||
isPermitPhotos = false
|
||||
isPrivate = false
|
||||
}
|
||||
|
||||
|
@ -87,23 +88,23 @@ func AddEdit() http.HandlerFunc {
|
|||
models.NewFieldDiff("Description", forum.Description, description),
|
||||
models.NewFieldDiff("Category", forum.Category, category),
|
||||
models.NewFieldDiff("Explicit", forum.Explicit, isExplicit),
|
||||
models.NewFieldDiff("PermitPhotos", forum.PermitPhotos, isPermitPhotos),
|
||||
}
|
||||
|
||||
forum.Title = title
|
||||
forum.Description = description
|
||||
forum.Category = category
|
||||
forum.Explicit = isExplicit
|
||||
forum.PermitPhotos = isPermitPhotos
|
||||
|
||||
// Forum Admin-only options: if the current viewer is not a forum admin, do not change these settings.
|
||||
// e.g.: the front-end checkboxes are hidden and don't want to accidentally unset these!
|
||||
if currentUser.HasAdminScope(config.ScopeForumAdmin) {
|
||||
diffs = append(diffs,
|
||||
models.NewFieldDiff("Privileged", forum.Privileged, isPrivileged),
|
||||
models.NewFieldDiff("PermitPhotos", forum.PermitPhotos, isPermitPhotos),
|
||||
models.NewFieldDiff("Private", forum.Private, isPrivate),
|
||||
)
|
||||
forum.Privileged = isPrivileged
|
||||
forum.PermitPhotos = isPermitPhotos
|
||||
forum.Private = isPrivate
|
||||
}
|
||||
|
||||
|
|
|
@ -55,7 +55,7 @@ func Forum() http.HandlerFunc {
|
|||
var pager = &models.Pagination{
|
||||
Page: 1,
|
||||
PerPage: config.PageSizeThreadList,
|
||||
Sort: "threads.updated_at desc",
|
||||
Sort: "updated_at desc",
|
||||
}
|
||||
pager.ParsePage(r)
|
||||
|
||||
|
|
|
@ -47,10 +47,6 @@ func NewPost() http.HandlerFunc {
|
|||
// well (pinned, explicit, noreply)
|
||||
isOriginalComment bool
|
||||
|
||||
// If neither the forum nor thread are explicit, show a hint to the user not to
|
||||
// share an explicit photo in their reply.
|
||||
explicitPhotoAllowed bool
|
||||
|
||||
// Polls
|
||||
pollOptions = []string{}
|
||||
pollExpires = 3
|
||||
|
@ -91,11 +87,6 @@ func NewPost() http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// Would an explicit photo attachment be allowed?
|
||||
if forum.Explicit || (thread != nil && thread.Explicit) {
|
||||
explicitPhotoAllowed = true
|
||||
}
|
||||
|
||||
// If the current user can moderate the forum thread, e.g. edit or delete posts.
|
||||
// Admins can edit always, user owners of forums can only delete.
|
||||
var canModerate = currentUser.HasAdminScope(config.ScopeForumModerator) ||
|
||||
|
@ -118,14 +109,7 @@ func NewPost() http.HandlerFunc {
|
|||
if len(quoteCommentID) > 0 {
|
||||
if i, err := strconv.Atoi(quoteCommentID); err == nil {
|
||||
if comment, err := models.GetComment(uint64(i)); err == nil {
|
||||
|
||||
// Prefill the message with the @mention and quoted post.
|
||||
message = fmt.Sprintf(
|
||||
"[@%s](/go/comment?id=%d)\n\n%s\n\n",
|
||||
comment.User.Username,
|
||||
comment.ID,
|
||||
markdown.Quotify(comment.Message),
|
||||
)
|
||||
message = markdown.Quotify(comment.Message) + "\n\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -502,14 +486,13 @@ func NewPost() http.HandlerFunc {
|
|||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Forum": forum,
|
||||
"Thread": thread,
|
||||
"Intent": intent,
|
||||
"PostTitle": title,
|
||||
"EditCommentID": editCommentID,
|
||||
"EditThreadSettings": isOriginalComment,
|
||||
"ExplicitPhotoAllowed": explicitPhotoAllowed,
|
||||
"Message": message,
|
||||
"Forum": forum,
|
||||
"Thread": thread,
|
||||
"Intent": intent,
|
||||
"PostTitle": title,
|
||||
"EditCommentID": editCommentID,
|
||||
"EditThreadSettings": isOriginalComment,
|
||||
"Message": message,
|
||||
|
||||
// Thread settings (for editing the original comment esp.)
|
||||
"IsPinned": isPinned,
|
||||
|
@ -517,9 +500,7 @@ func NewPost() http.HandlerFunc {
|
|||
"IsNoReply": isNoReply,
|
||||
|
||||
// Polls
|
||||
"PollOptions": pollOptions,
|
||||
"PollExpires": pollExpires,
|
||||
"PollExpiresOptions": config.PollExpires,
|
||||
"PollOptions": pollOptions,
|
||||
|
||||
// Attached photo.
|
||||
"CommentPhoto": commentPhoto,
|
||||
|
|
|
@ -20,10 +20,6 @@ func Thread() http.HandlerFunc {
|
|||
idStr = r.PathValue("id")
|
||||
forum *models.Forum
|
||||
thread *models.Thread
|
||||
|
||||
// If neither the forum nor thread are explicit, show a hint to the user not to
|
||||
// share an explicit photo in their reply.
|
||||
explicitPhotoAllowed bool
|
||||
)
|
||||
|
||||
if idStr == "" {
|
||||
|
@ -65,11 +61,6 @@ func Thread() http.HandlerFunc {
|
|||
// e.g. can we delete threads and posts, not edit them)
|
||||
var canModerate = forum.CanBeModeratedBy(currentUser)
|
||||
|
||||
// Would an explicit photo attachment be allowed?
|
||||
if forum.Explicit || thread.Explicit {
|
||||
explicitPhotoAllowed = true
|
||||
}
|
||||
|
||||
// Ping the view count on this thread.
|
||||
if err := thread.View(currentUser.ID); err != nil {
|
||||
log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err)
|
||||
|
@ -106,24 +97,16 @@ func Thread() http.HandlerFunc {
|
|||
// Is the current user subscribed to notifications on this thread?
|
||||
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
|
||||
|
||||
// Ping this user as having used the forums today.
|
||||
go func() {
|
||||
if err := models.LogDailyForumUser(currentUser); err != nil {
|
||||
log.Error("LogDailyForumUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
|
||||
}
|
||||
}()
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Forum": forum,
|
||||
"Thread": thread,
|
||||
"Comments": comments,
|
||||
"LikeMap": commentLikeMap,
|
||||
"PhotoMap": photos,
|
||||
"Pager": pager,
|
||||
"CanModerate": canModerate,
|
||||
"IsSubscribed": isSubscribed,
|
||||
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
|
||||
"ExplicitPhotoAllowed": explicitPhotoAllowed,
|
||||
"Forum": forum,
|
||||
"Thread": thread,
|
||||
"Comments": comments,
|
||||
"LikeMap": commentLikeMap,
|
||||
"PhotoMap": photos,
|
||||
"Pager": pager,
|
||||
"CanModerate": canModerate,
|
||||
"IsSubscribed": isSubscribed,
|
||||
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
|
@ -26,15 +26,13 @@ func Contact() http.HandlerFunc {
|
|||
subject = r.FormValue("subject")
|
||||
title = "Contact Us"
|
||||
message = r.FormValue("message")
|
||||
footer string // appends to the message only when posting the feedback
|
||||
replyTo = r.FormValue("email")
|
||||
trap1 = r.FormValue("url") != "https://"
|
||||
trap2 = r.FormValue("comment") != ""
|
||||
tableID int
|
||||
tableName string
|
||||
tableLabel string // front-end user feedback about selected report item
|
||||
aboutUser *models.User // associated user (e.g. owner of reported photo)
|
||||
messageRequired = true // unless we have a table ID to work with
|
||||
tableLabel string // front-end user feedback about selected report item
|
||||
messageRequired = true // unless we have a table ID to work with
|
||||
success = "Thank you for your feedback! Your message has been delivered to the website administrators."
|
||||
)
|
||||
|
||||
|
@ -57,7 +55,6 @@ func Contact() http.HandlerFunc {
|
|||
tableName = "users"
|
||||
if user, err := models.GetUser(uint64(tableID)); err == nil {
|
||||
tableLabel = fmt.Sprintf(`User account "%s"`, user.Username)
|
||||
aboutUser = user
|
||||
} else {
|
||||
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
|
||||
}
|
||||
|
@ -68,7 +65,6 @@ func Contact() http.HandlerFunc {
|
|||
if pic, err := models.GetPhoto(uint64(tableID)); err == nil {
|
||||
if user, err := models.GetUser(pic.UserID); err == nil {
|
||||
tableLabel = fmt.Sprintf(`A profile photo of user account "%s"`, user.Username)
|
||||
aboutUser = user
|
||||
} else {
|
||||
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
|
||||
}
|
||||
|
@ -78,43 +74,12 @@ func Contact() http.HandlerFunc {
|
|||
case "report.message":
|
||||
tableName = "messages"
|
||||
tableLabel = "Direct Message conversation"
|
||||
|
||||
// Find this message, and attach it to the report.
|
||||
if msg, err := models.GetMessage(uint64(tableID)); err == nil {
|
||||
var username = "[unavailable]"
|
||||
if sender, err := models.GetUser(msg.SourceUserID); err == nil {
|
||||
username = sender.Username
|
||||
aboutUser = sender
|
||||
}
|
||||
|
||||
footer = fmt.Sprintf(`
|
||||
|
||||
---
|
||||
|
||||
From: <a href="/u/%s">@%s</a>
|
||||
|
||||
%s`,
|
||||
username, username,
|
||||
markdown.Quotify(msg.Message),
|
||||
)
|
||||
}
|
||||
case "report.comment":
|
||||
tableName = "comments"
|
||||
|
||||
// Find this comment.
|
||||
if comment, err := models.GetComment(uint64(tableID)); err == nil {
|
||||
tableLabel = fmt.Sprintf(`A comment written by "%s"`, comment.User.Username)
|
||||
aboutUser = &comment.User
|
||||
footer = fmt.Sprintf(`
|
||||
|
||||
---
|
||||
|
||||
From: <a href="/u/%s">@%s</a>
|
||||
|
||||
%s`,
|
||||
comment.User.Username, comment.User.Username,
|
||||
markdown.Quotify(comment.Message),
|
||||
)
|
||||
} else {
|
||||
log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err)
|
||||
}
|
||||
|
@ -176,15 +141,11 @@ From: <a href="/u/%s">@%s</a>
|
|||
fb := &models.Feedback{
|
||||
Intent: intent,
|
||||
Subject: subject,
|
||||
Message: message + footer,
|
||||
Message: message,
|
||||
TableName: tableName,
|
||||
TableID: uint64(tableID),
|
||||
}
|
||||
|
||||
if aboutUser != nil {
|
||||
fb.AboutUserID = aboutUser.ID
|
||||
}
|
||||
|
||||
if currentUser != nil && currentUser.ID > 0 {
|
||||
fb.UserID = currentUser.ID
|
||||
} else if replyTo != "" {
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
package index
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/models/demographic"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
// Demographics page (/insights) to show a peek at website demographics.
|
||||
func Demographics() http.HandlerFunc {
|
||||
tmpl := templates.Must("demographics.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
refresh = r.FormValue("refresh") == "true"
|
||||
)
|
||||
|
||||
// Are we refreshing? Check if an admin is logged in.
|
||||
if refresh {
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "You must be logged in to do that!")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// Do the refresh?
|
||||
if currentUser.IsAdmin {
|
||||
_, err := demographic.Refresh()
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Refreshing the insights: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// Get website statistics to show on home page.
|
||||
demo, err := demographic.Get()
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't get website statistics: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
vars := map[string]interface{}{
|
||||
"Demographic": demo,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
|
@ -5,7 +5,6 @@ import (
|
|||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models/demographic"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
|
@ -19,17 +18,7 @@ func Create() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Get website statistics to show on home page.
|
||||
demo, err := demographic.Get()
|
||||
if err != nil {
|
||||
log.Error("demographic.Get: %s", err)
|
||||
}
|
||||
|
||||
vars := map[string]interface{}{
|
||||
"Demographic": demo,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
if err := tmpl.Execute(w, r, nil); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -1,166 +0,0 @@
|
|||
package mutelist
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
// Muted User list: view the list of muted accounts.
|
||||
func MuteList() http.HandlerFunc {
|
||||
tmpl := templates.Must("account/mute_list.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Unexpected error: could not get currentUser.")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// Get our mutelist.
|
||||
pager := &models.Pagination{
|
||||
PerPage: config.PageSizeMuteList,
|
||||
Sort: "updated_at desc",
|
||||
}
|
||||
pager.ParsePage(r)
|
||||
muted, err := models.PaginateMuteList(currentUser, pager)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't paginate mute list: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"MutedUsers": muted,
|
||||
"Pager": pager,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// AddUser to manually add someone to your mute list.
|
||||
func AddUser() http.HandlerFunc {
|
||||
tmpl := templates.Must("account/mute_list_add.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Query parameters.
|
||||
var (
|
||||
username = strings.ToLower(r.FormValue("username"))
|
||||
next = r.FormValue("next")
|
||||
context = models.MutedUserContext(r.FormValue("context"))
|
||||
listName = "Site Gallery" // TODO: more as contexts are added
|
||||
)
|
||||
|
||||
// Validate the Next URL.
|
||||
if !strings.HasPrefix(next, "/") {
|
||||
next = "/users/muted"
|
||||
}
|
||||
|
||||
// Validate acceptable contexts.
|
||||
if !models.IsValidMuteUserContext(context) {
|
||||
session.FlashError(w, r, "Unsupported mute context.")
|
||||
templates.Redirect(w, next)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the target user.
|
||||
user, err := models.FindUser(username)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "User Not Found")
|
||||
templates.Redirect(w, next)
|
||||
return
|
||||
}
|
||||
|
||||
vars := map[string]interface{}{
|
||||
"User": user,
|
||||
"Next": next,
|
||||
"Context": context,
|
||||
"MuteListName": listName,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// MuteUser controller: POST endpoint to add a mute.
|
||||
func MuteUser() http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Form fields
|
||||
var (
|
||||
username = strings.ToLower(r.PostFormValue("username"))
|
||||
next = r.PostFormValue("next")
|
||||
context = models.MutedUserContext(r.PostFormValue("context"))
|
||||
listName = "Site Gallery" // TODO: more as contexts are added
|
||||
unmute = r.PostFormValue("unmute") == "true"
|
||||
)
|
||||
|
||||
// Validate the Next URL.
|
||||
if !strings.HasPrefix(next, "/") {
|
||||
next = "/users/muted"
|
||||
}
|
||||
|
||||
// Validate acceptable contexts.
|
||||
if !models.IsValidMuteUserContext(context) {
|
||||
session.FlashError(w, r, "Unsupported mute context.")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// Get the current user.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't get CurrentUser: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// Get the target user.
|
||||
user, err := models.FindUser(username)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "User Not Found")
|
||||
templates.Redirect(w, next)
|
||||
return
|
||||
}
|
||||
|
||||
// Unmuting?
|
||||
if unmute {
|
||||
if err := models.RemoveMutedUser(currentUser.ID, user.ID, context); err != nil {
|
||||
session.FlashError(w, r, "Couldn't unmute this user: %s.", err)
|
||||
} else {
|
||||
session.Flash(w, r, "You have removed %s from your %s mute list.", user.Username, listName)
|
||||
|
||||
// Log the change.
|
||||
models.LogDeleted(currentUser, nil, "muted_users", user.ID, "Unmuted user "+user.Username+" from "+listName+".", nil)
|
||||
}
|
||||
templates.Redirect(w, next)
|
||||
return
|
||||
}
|
||||
|
||||
// Can't mute yourself.
|
||||
if currentUser.ID == user.ID {
|
||||
session.FlashError(w, r, "You can't mute yourself!")
|
||||
templates.Redirect(w, next)
|
||||
return
|
||||
}
|
||||
|
||||
// Mute the target user.
|
||||
if err := models.AddMutedUser(currentUser.ID, user.ID, context); err != nil {
|
||||
session.FlashError(w, r, "Couldn't mute this user: %s.", err)
|
||||
} else {
|
||||
session.Flash(w, r, "You have added %s to your %s mute list.", user.Username, listName)
|
||||
|
||||
// Log the change.
|
||||
models.LogCreated(currentUser, "muted_users", user.ID, "Mutes user "+user.Username+" on list "+listName+".")
|
||||
}
|
||||
|
||||
templates.Redirect(w, next)
|
||||
})
|
||||
}
|
|
@ -1,244 +0,0 @@
|
|||
package photo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
pphoto "code.nonshy.com/nonshy/website/pkg/photo"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
// BatchEdit controller (/photo/batch-edit?id=N) to change properties about your picture.
|
||||
func BatchEdit() http.HandlerFunc {
|
||||
tmpl := templates.Must("photo/batch_edit.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
// Form params
|
||||
intent = r.FormValue("intent")
|
||||
photoIDs []uint64
|
||||
)
|
||||
|
||||
// Collect the photo ID params.
|
||||
if value, ok := r.Form["id"]; ok {
|
||||
for _, idStr := range value {
|
||||
if photoID, err := strconv.Atoi(idStr); err == nil {
|
||||
photoIDs = append(photoIDs, uint64(photoID))
|
||||
} else {
|
||||
log.Error("parsing photo ID %s: %s", idStr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validation.
|
||||
if len(photoIDs) == 0 || len(photoIDs) > 100 {
|
||||
session.FlashError(w, r, "Invalid number of photo IDs.")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// Find these photos by ID.
|
||||
photos, err := models.GetPhotos(photoIDs)
|
||||
if err != nil {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Load the current user.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate permission to edit all of these photos.
|
||||
var (
|
||||
ownerIDs []uint64
|
||||
)
|
||||
for _, photo := range photos {
|
||||
|
||||
if !photo.CanBeEditedBy(currentUser) {
|
||||
templates.ForbiddenPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ownerIDs = append(ownerIDs, photo.UserID)
|
||||
}
|
||||
|
||||
// Load the photo owners.
|
||||
var (
|
||||
owners, _ = models.MapUsers(currentUser, ownerIDs)
|
||||
wasShy = map[uint64]bool{} // record if this change may make them shy
|
||||
redirectURI = "/" // go first owner's gallery
|
||||
|
||||
// Are any of them a user's profile photo? (map userID->true) so we know
|
||||
// who to unlink the picture from first and avoid a postgres error.
|
||||
wasUserProfilePicture = map[uint64]bool{}
|
||||
)
|
||||
for _, user := range owners {
|
||||
redirectURI = fmt.Sprintf("/u/%s/photos", user.Username)
|
||||
wasShy[user.ID] = user.IsShy()
|
||||
|
||||
// Check if this user's profile ID is being deleted.
|
||||
if user.ProfilePhotoID != nil {
|
||||
if _, ok := photos[*user.ProfilePhotoID]; ok {
|
||||
wasUserProfilePicture[user.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm batch deletion or edit.
|
||||
if r.Method == http.MethodPost {
|
||||
|
||||
confirm := r.PostFormValue("confirm") == "true"
|
||||
if !confirm {
|
||||
session.FlashError(w, r, "Confirm you want to modify this photo.")
|
||||
templates.Redirect(w, redirectURI)
|
||||
return
|
||||
}
|
||||
|
||||
// Which intent are they executing on?
|
||||
switch intent {
|
||||
case "delete":
|
||||
batchDeletePhotos(w, r, currentUser, photos, wasUserProfilePicture, owners, redirectURI)
|
||||
case "visibility":
|
||||
batchUpdateVisibility(w, r, currentUser, photos, owners)
|
||||
default:
|
||||
session.FlashError(w, r, "Unknown intent")
|
||||
}
|
||||
|
||||
// Maybe kick them from chat if this deletion makes them into a Shy Account.
|
||||
for _, user := range owners {
|
||||
user.FlushCaches()
|
||||
if !wasShy[user.ID] && user.IsShy() {
|
||||
if _, err := chat.MaybeDisconnectUser(user); err != nil {
|
||||
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the user to their gallery.
|
||||
templates.Redirect(w, redirectURI)
|
||||
return
|
||||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Intent": intent,
|
||||
"Photos": photos,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Batch DELETE executive handler.
|
||||
func batchDeletePhotos(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
currentUser *models.User,
|
||||
photos map[uint64]*models.Photo,
|
||||
wasUserProfilePicture map[uint64]bool,
|
||||
owners map[uint64]*models.User,
|
||||
redirectURI string,
|
||||
) {
|
||||
// Delete all the photos.
|
||||
for _, photo := range photos {
|
||||
|
||||
// Was this someone's profile picture ID?
|
||||
if wasUserProfilePicture[photo.UserID] {
|
||||
log.Debug("Delete Photo: was the user's profile photo, unset ProfilePhotoID")
|
||||
if owner, ok := owners[photo.UserID]; ok {
|
||||
if err := owner.RemoveProfilePhoto(); err != nil {
|
||||
session.FlashError(w, r, "Error unsetting your current profile photo: %s", err)
|
||||
templates.Redirect(w, redirectURI)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the images from disk.
|
||||
for _, filename := range []string{
|
||||
photo.Filename,
|
||||
photo.CroppedFilename,
|
||||
} {
|
||||
if len(filename) > 0 {
|
||||
if err := pphoto.Delete(filename); err != nil {
|
||||
session.FlashError(w, r, "Delete Photo: couldn't remove file from disk: %s: %s", filename, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Take back notifications on it.
|
||||
models.RemoveNotification("photos", photo.ID)
|
||||
|
||||
if err := photo.Delete(); err != nil {
|
||||
session.FlashError(w, r, "Couldn't delete photo: %s", err)
|
||||
templates.Redirect(w, redirectURI)
|
||||
return
|
||||
}
|
||||
|
||||
// Log the change.
|
||||
if owner, ok := owners[photo.UserID]; ok {
|
||||
models.LogDeleted(owner, currentUser, "photos", photo.ID, "Deleted the photo.", photo)
|
||||
}
|
||||
}
|
||||
|
||||
// Maybe revoke their Certified status if they have cleared out their gallery.
|
||||
for _, owner := range owners {
|
||||
if revoked := models.MaybeRevokeCertificationForEmptyGallery(owner); revoked {
|
||||
if owner.ID == currentUser.ID {
|
||||
session.FlashError(w, r, "Notice: because you have deleted your entire photo gallery, your Certification status has been automatically revoked.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session.Flash(w, r, "%d photo(s) deleted!", len(photos))
|
||||
}
|
||||
|
||||
// Batch DELETE executive handler.
|
||||
func batchUpdateVisibility(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
currentUser *models.User,
|
||||
photos map[uint64]*models.Photo,
|
||||
owners map[uint64]*models.User,
|
||||
) {
|
||||
// Visibility setting.
|
||||
visibility := r.PostFormValue("visibility")
|
||||
|
||||
// Delete all the photos.
|
||||
for _, photo := range photos {
|
||||
|
||||
// Diff for the ChangeLog.
|
||||
diffs := []models.FieldDiff{
|
||||
models.NewFieldDiff("Visibility", photo.Visibility, visibility),
|
||||
}
|
||||
|
||||
photo.Visibility = models.PhotoVisibility(visibility)
|
||||
|
||||
// If going private, take back notifications on it.
|
||||
if photo.Visibility == models.PhotoPrivate {
|
||||
models.RemoveNotification("photos", photo.ID)
|
||||
}
|
||||
|
||||
if err := photo.Save(); err != nil {
|
||||
session.FlashError(w, r, "Error saving photo #%d: %s", photo.ID, err)
|
||||
}
|
||||
|
||||
// Log the change.
|
||||
if owner, ok := owners[photo.UserID]; ok {
|
||||
// Log the change.
|
||||
models.LogUpdated(owner, currentUser, "photos", photo.ID, "Updated the photo's settings.", diffs)
|
||||
}
|
||||
}
|
||||
|
||||
session.Flash(w, r, "%d photo(s) updated!", len(photos))
|
||||
}
|
|
@ -434,21 +434,17 @@ func AdminCertification() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Notify the user via email.
|
||||
if err := mail.LockSending("cert_rejected", user.Email, config.EmailDebounceDefault); err == nil {
|
||||
if err := mail.Send(mail.Message{
|
||||
To: user.Email,
|
||||
Subject: "Your certification photo has been denied",
|
||||
Template: "email/certification_rejected.html",
|
||||
Data: map[string]interface{}{
|
||||
"Username": user.Username,
|
||||
"AdminComment": comment,
|
||||
"URL": config.Current.BaseURL + "/photo/certification",
|
||||
},
|
||||
}); err != nil {
|
||||
session.FlashError(w, r, "Note: failed to email user about the rejection: %s", err)
|
||||
}
|
||||
} else {
|
||||
log.Error("LockSending: cert_rejected e-mail is not sent to %s: one was sent recently", user.Email)
|
||||
if err := mail.Send(mail.Message{
|
||||
To: user.Email,
|
||||
Subject: "Your certification photo has been rejected",
|
||||
Template: "email/certification_rejected.html",
|
||||
Data: map[string]interface{}{
|
||||
"Username": user.Username,
|
||||
"AdminComment": comment,
|
||||
"URL": config.Current.BaseURL + "/photo/certification",
|
||||
},
|
||||
}); err != nil {
|
||||
session.FlashError(w, r, "Note: failed to email user about the rejection: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -507,20 +503,16 @@ func AdminCertification() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Notify the user via email.
|
||||
if err := mail.LockSending("cert_approved", user.Email, config.EmailDebounceDefault); err == nil {
|
||||
if err := mail.Send(mail.Message{
|
||||
To: user.Email,
|
||||
Subject: "Your certification photo has been approved!",
|
||||
Template: "email/certification_approved.html",
|
||||
Data: map[string]interface{}{
|
||||
"Username": user.Username,
|
||||
"URL": config.Current.BaseURL,
|
||||
},
|
||||
}); err != nil {
|
||||
session.FlashError(w, r, "Note: failed to email user about the approval: %s", err)
|
||||
}
|
||||
} else {
|
||||
log.Error("LockSending: cert_approved e-mail is not sent to %s: one was sent recently", user.Email)
|
||||
if err := mail.Send(mail.Message{
|
||||
To: user.Email,
|
||||
Subject: "Your certification photo has been approved!",
|
||||
Template: "email/certification_approved.html",
|
||||
Data: map[string]interface{}{
|
||||
"Username": user.Username,
|
||||
"URL": config.Current.BaseURL,
|
||||
},
|
||||
}); err != nil {
|
||||
session.FlashError(w, r, "Note: failed to email user about the approval: %s", err)
|
||||
}
|
||||
|
||||
// Log the change.
|
||||
|
|
|
@ -71,9 +71,6 @@ func Edit() http.HandlerFunc {
|
|||
|
||||
// Are we saving the changes?
|
||||
if r.Method == http.MethodPost {
|
||||
// Record if this change is going to make them a Shy Account.
|
||||
var wasShy = currentUser.IsShy()
|
||||
|
||||
var (
|
||||
caption = strings.TrimSpace(r.FormValue("caption"))
|
||||
altText = strings.TrimSpace(r.FormValue("alt_text"))
|
||||
|
@ -88,9 +85,6 @@ func Edit() http.HandlerFunc {
|
|||
|
||||
// Are we GOING private?
|
||||
goingPrivate = visibility == models.PhotoPrivate && visibility != photo.Visibility
|
||||
|
||||
// Is the user fighting an 'Explicit' tag added by the community?
|
||||
isFightingExplicitFlag = photo.Flagged && photo.Explicit && !isExplicit
|
||||
)
|
||||
|
||||
if len(altText) > config.AltTextMaxLength {
|
||||
|
@ -111,24 +105,6 @@ func Edit() http.HandlerFunc {
|
|||
models.NewFieldDiff("Visibility", photo.Visibility, visibility),
|
||||
}
|
||||
|
||||
// Admin label options.
|
||||
if requestUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||
var adminLabel string
|
||||
if labels, ok := r.PostForm["admin_label"]; ok {
|
||||
adminLabel = strings.Join(labels, ",")
|
||||
}
|
||||
diffs = append(diffs,
|
||||
models.NewFieldDiff("Admin Label", photo.AdminLabel, adminLabel),
|
||||
)
|
||||
|
||||
photo.AdminLabel = adminLabel
|
||||
}
|
||||
|
||||
// Admin label: forced explicit?
|
||||
if photo.HasAdminLabelForceExplicit() {
|
||||
isExplicit = true
|
||||
}
|
||||
|
||||
photo.Caption = caption
|
||||
photo.AltText = altText
|
||||
photo.Explicit = isExplicit
|
||||
|
@ -162,34 +138,6 @@ func Edit() http.HandlerFunc {
|
|||
setProfilePic = false
|
||||
}
|
||||
|
||||
// If the user is fighting a recent Explicit flag from the community.
|
||||
if isFightingExplicitFlag {
|
||||
|
||||
// Notify the admin (unless we are an admin).
|
||||
if !requestUser.IsAdmin {
|
||||
fb := &models.Feedback{
|
||||
Intent: "report",
|
||||
Subject: "Explicit photo flag dispute",
|
||||
UserID: currentUser.ID,
|
||||
TableName: "photos",
|
||||
TableID: photo.ID,
|
||||
Message: "A user's photo was recently **flagged by the community** as Explicit, and its owner " +
|
||||
"has **removed** the Explicit setting.\n\n" +
|
||||
"Please check out the photo below and verify what its Explicit setting should be:",
|
||||
}
|
||||
|
||||
if err := models.CreateFeedback(fb); err != nil {
|
||||
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow this change but clear the Flagged status.
|
||||
photo.Flagged = false
|
||||
|
||||
// Clear the notification about this.
|
||||
models.RemoveSpecificNotification(currentUser.ID, models.NotificationExplicitPhoto, "photos", photo.ID)
|
||||
}
|
||||
|
||||
if err := photo.Save(); err != nil {
|
||||
session.FlashError(w, r, "Couldn't save photo: %s", err)
|
||||
}
|
||||
|
@ -210,11 +158,8 @@ func Edit() http.HandlerFunc {
|
|||
models.LogUpdated(currentUser, requestUser, "photos", photo.ID, "Updated the photo's settings.", diffs)
|
||||
|
||||
// Maybe kick them from the chat if this photo save makes them a Shy Account.
|
||||
currentUser.FlushCaches()
|
||||
if !wasShy && currentUser.IsShy() {
|
||||
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
|
||||
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
|
||||
}
|
||||
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
|
||||
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
|
||||
}
|
||||
|
||||
// If this picture has moved to Private, revoke any notification we gave about it before.
|
||||
|
@ -232,10 +177,6 @@ func Edit() http.HandlerFunc {
|
|||
"EditPhoto": photo,
|
||||
"SiteGalleryThrottled": SiteGalleryThrottled,
|
||||
"SiteGalleryThrottleLimit": config.SiteGalleryRateLimitMax,
|
||||
|
||||
// Available admin labels enum.
|
||||
"RequestUser": requestUser,
|
||||
"AvailableAdminLabels": config.AdminLabelPhotoOptions,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
|
@ -246,10 +187,121 @@ func Edit() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Delete controller (/photo/Delete?id=N) to change properties about your picture.
|
||||
//
|
||||
// DEPRECATED: send them to the batch-edit endpoint.
|
||||
func Delete() http.HandlerFunc {
|
||||
// Reuse the upload page but with an EditPhoto variable.
|
||||
tmpl := templates.Must("photo/delete.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
templates.Redirect(w, fmt.Sprintf("/photo/batch-edit?intent=delete&id=%s", r.FormValue("id")))
|
||||
// Query params.
|
||||
photoID, err := strconv.Atoi(r.FormValue("id"))
|
||||
if err != nil {
|
||||
log.Error("photo.Delete: failed to parse `id` param (%s) as int: %s", r.FormValue("id"), err)
|
||||
session.FlashError(w, r, "Photo 'id' parameter required.")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// Page to redirect to in case of errors.
|
||||
redirect := fmt.Sprintf("%s?id=%d", r.URL.Path, photoID)
|
||||
|
||||
// Find this photo by ID.
|
||||
photo, err := models.GetPhoto(uint64(photoID))
|
||||
if err != nil {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Load the current user.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// In case an admin is editing this photo: remember the HTTP request current user,
|
||||
// before the currentUser may be set to the photo's owner below.
|
||||
var requestUser = currentUser
|
||||
|
||||
// Do we have permission for this photo?
|
||||
if photo.UserID != currentUser.ID {
|
||||
if !currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||
templates.ForbiddenPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the owner of this photo and assume currentUser is them for the remainder
|
||||
// of this controller.
|
||||
if user, err := models.GetUser(photo.UserID); err != nil {
|
||||
session.FlashError(w, r, "Couldn't get the owner User for this photo!")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
} else {
|
||||
currentUser = user
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm deletion?
|
||||
if r.Method == http.MethodPost {
|
||||
confirm := r.PostFormValue("confirm") == "true"
|
||||
if !confirm {
|
||||
session.FlashError(w, r, "Confirm you want to delete this photo.")
|
||||
templates.Redirect(w, redirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Was this our profile picture?
|
||||
if currentUser.ProfilePhotoID != nil && *currentUser.ProfilePhotoID == photo.ID {
|
||||
log.Debug("Delete Photo: was the user's profile photo, unset ProfilePhotoID")
|
||||
if err := currentUser.RemoveProfilePhoto(); err != nil {
|
||||
session.FlashError(w, r, "Error unsetting your current profile photo: %s", err)
|
||||
templates.Redirect(w, redirect)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the images from disk.
|
||||
for _, filename := range []string{
|
||||
photo.Filename,
|
||||
photo.CroppedFilename,
|
||||
} {
|
||||
if len(filename) > 0 {
|
||||
if err := pphoto.Delete(filename); err != nil {
|
||||
log.Error("Delete Photo: couldn't remove file from disk: %s: %s", filename, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Take back notifications on it.
|
||||
models.RemoveNotification("photos", photo.ID)
|
||||
|
||||
if err := photo.Delete(); err != nil {
|
||||
session.FlashError(w, r, "Couldn't delete photo: %s", err)
|
||||
templates.Redirect(w, redirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Log the change.
|
||||
models.LogDeleted(currentUser, requestUser, "photos", photo.ID, "Deleted the photo.", photo)
|
||||
|
||||
session.Flash(w, r, "Photo deleted!")
|
||||
|
||||
// Maybe kick them from chat if this deletion makes them into a Shy Account.
|
||||
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
|
||||
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
|
||||
}
|
||||
|
||||
// Return the user to their gallery.
|
||||
templates.Redirect(w, "/u/"+currentUser.Username+"/photos")
|
||||
return
|
||||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Photo": photo,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -42,12 +42,6 @@ func Private() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Collect user IDs for some mappings.
|
||||
var userIDs = []uint64{}
|
||||
for _, user := range users {
|
||||
userIDs = append(userIDs, user.ID)
|
||||
}
|
||||
|
||||
// Map reverse grantee statuses.
|
||||
var GranteeMap interface{}
|
||||
if isGrantee {
|
||||
|
@ -64,12 +58,6 @@ func Private() http.HandlerFunc {
|
|||
"GranteeMap": GranteeMap,
|
||||
"Users": users,
|
||||
"Pager": pager,
|
||||
|
||||
// Mapped user statuses for frontend cards.
|
||||
"PhotoCountMap": models.MapPhotoCountsByVisibility(users, models.PhotoPrivate),
|
||||
"FriendMap": models.MapFriends(currentUser, users),
|
||||
"LikedMap": models.MapLikes(currentUser, "users", userIDs),
|
||||
"ShyMap": models.MapShyAccounts(users),
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
@ -141,15 +129,6 @@ func Share() http.HandlerFunc {
|
|||
intent = r.PostFormValue("intent")
|
||||
)
|
||||
|
||||
// Is the recipient blocking this photo share?
|
||||
if intent != "decline" && intent != "revoke" {
|
||||
if ok, err := models.ShouldShowPrivateUnlockPrompt(currentUser, user); !ok {
|
||||
session.FlashError(w, r, "You are unable to share your private photos with %s: %s.", user.Username, err)
|
||||
templates.Redirect(w, "/u/"+user.Username)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If submitting, do it and redirect.
|
||||
if intent == "submit" {
|
||||
models.UnlockPrivatePhotos(currentUser.ID, user.ID)
|
||||
|
@ -185,21 +164,6 @@ func Share() http.HandlerFunc {
|
|||
log.Error("RevokePrivatePhotoNotifications(%s): %s", currentUser.Username, err)
|
||||
}
|
||||
return
|
||||
} else if intent == "decline" {
|
||||
// Decline = they shared with me and we do not want it.
|
||||
models.RevokePrivatePhotos(user.ID, currentUser.ID)
|
||||
session.Flash(w, r, "You have declined access to see %s's private photos.", user.Username)
|
||||
|
||||
// Remove any notification we created when the grant was given.
|
||||
models.RemoveSpecificNotification(currentUser.ID, models.NotificationPrivatePhoto, "__private_photos", user.ID)
|
||||
|
||||
// Revoke any "has uploaded a new private photo" notifications in this user's list.
|
||||
if err := models.RevokePrivatePhotoNotifications(user, currentUser); err != nil {
|
||||
log.Error("RevokePrivatePhotoNotifications(%s): %s", user.Username, err)
|
||||
}
|
||||
|
||||
templates.Redirect(w, "/photo/private?view=grantee")
|
||||
return
|
||||
}
|
||||
|
||||
// The other intent is "preview" so the user gets the confirmation
|
||||
|
|
|
@ -4,7 +4,6 @@ import (
|
|||
"net/http"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
|
@ -18,9 +17,6 @@ func SiteGallery() http.HandlerFunc {
|
|||
var sortWhitelist = []string{
|
||||
"created_at desc",
|
||||
"created_at asc",
|
||||
"like_count desc",
|
||||
"comment_count desc",
|
||||
"views desc",
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -125,13 +121,6 @@ func SiteGallery() http.HandlerFunc {
|
|||
likeMap := models.MapLikes(currentUser, "photos", photoIDs)
|
||||
commentMap := models.MapCommentCounts("photos", photoIDs)
|
||||
|
||||
// Ping this user as having used the forums today.
|
||||
go func() {
|
||||
if err := models.LogDailyGalleryUser(currentUser); err != nil {
|
||||
log.Error("LogDailyGalleryUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
|
||||
}
|
||||
}()
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"IsSiteGallery": true,
|
||||
"Photos": photos,
|
||||
|
|
|
@ -19,9 +19,6 @@ func UserPhotos() http.HandlerFunc {
|
|||
"pinned desc nulls last, updated_at desc",
|
||||
"created_at desc",
|
||||
"created_at asc",
|
||||
"like_count desc",
|
||||
"comment_count desc",
|
||||
"views desc",
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -195,16 +192,12 @@ func UserPhotos() http.HandlerFunc {
|
|||
areNotificationsMuted = !v
|
||||
}
|
||||
|
||||
// Should the current user be able to share their private photos with the target?
|
||||
showPrivateUnlockPrompt, _ := models.ShouldShowPrivateUnlockPrompt(currentUser, user)
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"IsOwnPhotos": currentUser.ID == user.ID,
|
||||
"IsShyUser": isShy,
|
||||
"IsShyFrom": isShyFrom,
|
||||
"IsMyPrivateUnlockedFor": isGranted, // have WE granted THIS USER to see our private pics?
|
||||
"AreWeGrantedPrivate": isGrantee, // have THEY granted US private photo access.
|
||||
"ShowPrivateUnlockPrompt": showPrivateUnlockPrompt,
|
||||
"AreFriends": areFriends,
|
||||
"AreNotificationsMuted": areNotificationsMuted,
|
||||
"ProfilePictureHiddenVisibility": profilePictureHidden,
|
||||
|
|
|
@ -35,12 +35,6 @@ func View() http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// Load the current user in case they are viewing their own page.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
||||
}
|
||||
|
||||
// Find the photo's owner.
|
||||
user, err := models.GetUser(photo.UserID)
|
||||
if err != nil {
|
||||
|
@ -48,10 +42,34 @@ func View() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
if ok, err := photo.CanBeSeenBy(currentUser); !ok {
|
||||
log.Error("Photo %d can't be seen by %s: %s", photo.ID, currentUser.Username, err)
|
||||
session.FlashError(w, r, "Photo Not Found")
|
||||
templates.Redirect(w, "/")
|
||||
// Load the current user in case they are viewing their own page.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
||||
}
|
||||
var isOwnPhoto = currentUser.ID == user.ID
|
||||
|
||||
// Is either one blocking?
|
||||
if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Is this user private and we're not friends?
|
||||
var (
|
||||
areFriends = models.AreFriends(user.ID, currentUser.ID)
|
||||
isPrivate = user.Visibility == models.UserVisibilityPrivate && !areFriends
|
||||
)
|
||||
if isPrivate && !currentUser.IsAdmin && !isOwnPhoto {
|
||||
session.FlashError(w, r, "This user's profile page and photo gallery are private.")
|
||||
templates.Redirect(w, "/u/"+user.Username)
|
||||
return
|
||||
}
|
||||
|
||||
// Is this a private photo and are we allowed to see?
|
||||
isGranted := models.IsPrivateUnlocked(user.ID, currentUser.ID)
|
||||
if photo.Visibility == models.PhotoPrivate && !isGranted && !isOwnPhoto && !currentUser.IsAdmin {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -84,11 +102,6 @@ func View() http.HandlerFunc {
|
|||
// Is the current user subscribed to notifications on this thread?
|
||||
_, isSubscribed := models.IsSubscribed(currentUser, "photos", photo.ID)
|
||||
|
||||
// Mark this photo as "Viewed" by the user.
|
||||
if err := photo.View(currentUser); err != nil {
|
||||
log.Error("Update photo(%d) views: %s", photo.ID, err)
|
||||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"IsOwnPhoto": currentUser.ID == user.ID,
|
||||
"User": user,
|
||||
|
|
|
@ -1,71 +0,0 @@
|
|||
package encryption
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
// StandardClaims returns a standard JWT claim for a username.
|
||||
//
|
||||
// It will include values for Subject (username), Issuer (site title), ExpiresAt, IssuedAt, NotBefore.
|
||||
//
|
||||
// If the userID is >0, the ID field is included.
|
||||
func StandardClaims(userID uint64, username string, expiresAt time.Time) jwt.RegisteredClaims {
|
||||
claim := jwt.RegisteredClaims{
|
||||
Subject: username,
|
||||
Issuer: config.Title,
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
}
|
||||
if userID > 0 {
|
||||
claim.ID = fmt.Sprintf("%d", userID)
|
||||
}
|
||||
return claim
|
||||
}
|
||||
|
||||
// SignClaims creates and returns a signed JWT token.
|
||||
func SignClaims(claims jwt.Claims, secret []byte) (string, error) {
|
||||
// Get our Chat JWT secret.
|
||||
if len(secret) == 0 {
|
||||
return "", errors.New("JWT secret key is not configured")
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
ss, err := token.SignedString(secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return ss, nil
|
||||
}
|
||||
|
||||
// ValidateClaims checks a JWT token is signed by the site key and returns the claims.
|
||||
func ValidateClaims(tokenStr string, secret []byte, v jwt.Claims) (jwt.Claims, bool, error) {
|
||||
// Handle a JWT authentication token.
|
||||
var (
|
||||
claims jwt.Claims
|
||||
authOK bool
|
||||
)
|
||||
if tokenStr != "" {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, v, func(token *jwt.Token) (interface{}, error) {
|
||||
return secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, false, errors.New("token was not valid")
|
||||
}
|
||||
|
||||
claims = token.Claims
|
||||
authOK = true
|
||||
}
|
||||
|
||||
return claims, authOK, nil
|
||||
}
|
|
@ -2,18 +2,12 @@
|
|||
package markdown
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/shurcooL/github_flavored_markdown"
|
||||
)
|
||||
|
||||
var (
|
||||
RegexpHTMLTag = regexp.MustCompile(`<(.|\n)+?>`)
|
||||
RegexpMarkdownLink = regexp.MustCompile(`\[([^\[\]]*)\]\((.*?)\)`)
|
||||
)
|
||||
|
||||
// Render markdown from untrusted sources.
|
||||
func Render(input string) string {
|
||||
// Render Markdown to HTML.
|
||||
|
@ -33,28 +27,3 @@ func Quotify(input string) string {
|
|||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
/*
|
||||
DeMarkify strips some Markdown syntax from plain text snippets.
|
||||
|
||||
It is especially useful on Forum views that show plain text contents of posts, and especially
|
||||
for linked at-mentions of the form `[@username](/go/comment?id=1234)` so that those can be
|
||||
simplified down to just the `@username` text contents.
|
||||
|
||||
This function will strip and simplify:
|
||||
|
||||
- Markdown hyperlinks as spelled out above
|
||||
- Literal HTML tags such as <a href="">
|
||||
*/
|
||||
func DeMarkify(input string) string {
|
||||
// Strip all standard HTML tags.
|
||||
input = RegexpHTMLTag.ReplaceAllString(input, "")
|
||||
|
||||
// Replace Markdown hyperlinks.
|
||||
matches := RegexpMarkdownLink.FindAllStringSubmatch(input, -1)
|
||||
for _, m := range matches {
|
||||
input = strings.ReplaceAll(input, m[0], m[1])
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
package markdown_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/markdown"
|
||||
)
|
||||
|
||||
func TestDeMarkify(t *testing.T) {
|
||||
var cases = []struct {
|
||||
Input string
|
||||
Expect string
|
||||
}{
|
||||
{
|
||||
Input: "Hello world!",
|
||||
Expect: "Hello world!",
|
||||
},
|
||||
{
|
||||
Input: "[@username](/go/comment?id=1234) Very well said!",
|
||||
Expect: "@username Very well said!",
|
||||
},
|
||||
{
|
||||
Input: `<a href="https://wikipedia.org">Wikipedia</a> said **otherwise.**`,
|
||||
Expect: "Wikipedia said **otherwise.**",
|
||||
},
|
||||
{
|
||||
Input: "[Here](/here) is one [link](https://example.com), while [Here](/here) is [another](/another).",
|
||||
Expect: "Here is one link, while Here is another.",
|
||||
},
|
||||
}
|
||||
|
||||
for i, tc := range cases {
|
||||
actual := markdown.DeMarkify(tc.Input)
|
||||
if actual != tc.Expect {
|
||||
t.Errorf("Test #%d: expected '%s' but got '%s'", i, tc.Expect, actual)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -48,8 +48,9 @@ func LoginRequired(handler http.Handler) http.Handler {
|
|||
// Ping LastLoginAt for long lived sessions, but not if impersonated.
|
||||
var pingLastLoginAt bool
|
||||
if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown && !session.Impersonated(r) {
|
||||
user.LastLoginAt = time.Now()
|
||||
pingLastLoginAt = true
|
||||
if err := user.PingLastLoginAt(); err != nil {
|
||||
if err := user.Save(); err != nil {
|
||||
log.Error("LoginRequired: couldn't refresh LastLoginAt for user %s: %s", user.Username, err)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
package backfill
|
||||
|
||||
import (
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
)
|
||||
|
||||
// BackfillPhotoCounts recomputes the cached Likes and Comment counts on photos.
|
||||
func BackfillPhotoCounts() error {
|
||||
res := models.DB.Exec(`
|
||||
UPDATE photos
|
||||
SET like_count = (
|
||||
SELECT count(id)
|
||||
FROM likes
|
||||
WHERE table_name='photos'
|
||||
AND table_id=photos.id
|
||||
),
|
||||
comment_count = (
|
||||
SELECT count(id)
|
||||
FROM comments
|
||||
WHERE table_name='photos'
|
||||
AND table_id=photos.id
|
||||
);
|
||||
`)
|
||||
return res.Error
|
||||
}
|
|
@ -166,54 +166,6 @@ func BlockedUserIDsByUser(userId uint64) []uint64 {
|
|||
return userIDs
|
||||
}
|
||||
|
||||
// GetAllBlockedUserIDs returns the forward and reverse lists of blocked user IDs for the user.
|
||||
func GetAllBlockedUserIDs(user *User) (forward, reverse []uint64) {
|
||||
var (
|
||||
bs = []*Block{}
|
||||
)
|
||||
DB.Where("source_user_id = ? OR target_user_id = ?", user.ID, user.ID).Find(&bs)
|
||||
for _, row := range bs {
|
||||
if row.SourceUserID == user.ID {
|
||||
forward = append(forward, row.TargetUserID)
|
||||
} else if row.TargetUserID == user.ID {
|
||||
reverse = append(reverse, row.SourceUserID)
|
||||
}
|
||||
}
|
||||
return forward, reverse
|
||||
}
|
||||
|
||||
// BulkRestoreBlockedUserIDs inserts many blocked user IDs in one query.
|
||||
//
|
||||
// Returns the count of blocks added.
|
||||
func BulkRestoreBlockedUserIDs(user *User, forward, reverse []uint64) (int, error) {
|
||||
var bs = []*Block{}
|
||||
|
||||
// Forward list.
|
||||
for _, uid := range forward {
|
||||
bs = append(bs, &Block{
|
||||
SourceUserID: user.ID,
|
||||
TargetUserID: uid,
|
||||
})
|
||||
}
|
||||
|
||||
// Reverse list.
|
||||
for _, uid := range reverse {
|
||||
bs = append(bs, &Block{
|
||||
SourceUserID: uid,
|
||||
TargetUserID: user.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// Anything to do?
|
||||
if len(bs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Batch create.
|
||||
res := DB.Create(bs)
|
||||
return len(bs), res.Error
|
||||
}
|
||||
|
||||
// BlockedUsernames returns all usernames blocked by (or blocking) the user.
|
||||
func BlockedUsernames(user *User) []string {
|
||||
var (
|
||||
|
@ -247,7 +199,6 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
|||
reverse = []*Block{} // Users who block the target
|
||||
userIDs = []uint64{user.ID}
|
||||
usernames = map[uint64]string{}
|
||||
admins = map[uint64]bool{}
|
||||
)
|
||||
|
||||
// Get the complete blocklist and bucket them into forward and reverse.
|
||||
|
@ -267,7 +218,6 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
|||
type scanItem struct {
|
||||
ID uint64
|
||||
Username string
|
||||
IsAdmin bool
|
||||
}
|
||||
var scan = []scanItem{}
|
||||
if res := DB.Table(
|
||||
|
@ -275,7 +225,6 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
|||
).Select(
|
||||
"id",
|
||||
"username",
|
||||
"is_admin",
|
||||
).Where(
|
||||
"id IN ?", userIDs,
|
||||
).Scan(&scan); res.Error != nil {
|
||||
|
@ -284,7 +233,6 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
|||
|
||||
for _, row := range scan {
|
||||
usernames[row.ID] = row.Username
|
||||
admins[row.ID] = row.IsAdmin
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -297,7 +245,6 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
|||
if username, ok := usernames[row.TargetUserID]; ok {
|
||||
result.Blocks = append(result.Blocks, BlocklistInsightUser{
|
||||
Username: username,
|
||||
IsAdmin: admins[row.TargetUserID],
|
||||
Date: row.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
@ -306,7 +253,6 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
|||
if username, ok := usernames[row.SourceUserID]; ok {
|
||||
result.BlockedBy = append(result.BlockedBy, BlocklistInsightUser{
|
||||
Username: username,
|
||||
IsAdmin: admins[row.SourceUserID],
|
||||
Date: row.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
@ -322,7 +268,6 @@ type BlocklistInsight struct {
|
|||
|
||||
type BlocklistInsightUser struct {
|
||||
Username string
|
||||
IsAdmin bool
|
||||
Date time.Time
|
||||
}
|
||||
|
||||
|
|
|
@ -2,10 +2,8 @@ package models
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
|
@ -103,64 +101,6 @@ func CountCertificationPhotosNeedingApproval() int64 {
|
|||
return count
|
||||
}
|
||||
|
||||
// MaybeRevokeCertificationForEmptyGallery will delete a user's certification photo if they delete every picture from their gallery.
|
||||
//
|
||||
// Returns true if their certification was revoked.
|
||||
func MaybeRevokeCertificationForEmptyGallery(user *User) bool {
|
||||
cert, err := GetCertificationPhoto(user.ID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Ignore if their cert photo status is not applicable to be revoked.
|
||||
if cert.Status == CertificationPhotoNeeded || cert.Status == CertificationPhotoRejected {
|
||||
return false
|
||||
}
|
||||
|
||||
if count := CountPhotos(user.ID); count == 0 {
|
||||
// Revoke their cert status.
|
||||
cert.Status = CertificationPhotoRejected
|
||||
cert.SecondaryVerified = false
|
||||
cert.AdminComment = "Your certification photo has been automatically rejected because you have deleted every photo on your gallery. " +
|
||||
"To restore your certified status, please upload photos to your gallery and submit a new Certification Photo for approval."
|
||||
|
||||
if err := cert.Save(); err != nil {
|
||||
log.Error("MaybeRevokeCertificationForEmptyGallery(%s): %s", user.Username, err)
|
||||
}
|
||||
|
||||
// Update the user's Certified flag. Note: we freshly query the user here in case they had JUST deleted
|
||||
// their default profile picture - so that we don't (re)set their old ProfilePhotoID by accident!
|
||||
if user, err := GetUser(user.ID); err == nil {
|
||||
user.Certified = false
|
||||
if err := user.Save(); err != nil {
|
||||
log.Error("MaybeRevokeCertificationForEmptyGallery(%s): saving user certified flag: %s", user.Username, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Notify the site admin for visibility.
|
||||
fb := &Feedback{
|
||||
Intent: "report",
|
||||
Subject: "A certified user has deleted all their pictures",
|
||||
UserID: user.ID,
|
||||
TableName: "users",
|
||||
TableID: user.ID,
|
||||
Message: fmt.Sprintf(
|
||||
"The username **@%s** has deleted every picture in their gallery, and so their Certification Photo status has been revoked.",
|
||||
user.Username,
|
||||
),
|
||||
}
|
||||
|
||||
// Save the feedback.
|
||||
if err := CreateFeedback(fb); err != nil {
|
||||
log.Error("Couldn't save feedback from user auto-revoking their cert photo: %s", err)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Save photo.
|
||||
func (p *CertificationPhoto) Save() error {
|
||||
result := DB.Save(p)
|
||||
|
|
|
@ -13,8 +13,8 @@ import (
|
|||
// Comment table - in forum threads, on profiles or photos, etc.
|
||||
type Comment struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
TableName string `gorm:"index:idx_comment_composite"`
|
||||
TableID uint64 `gorm:"index:idx_comment_composite"`
|
||||
TableName string `gorm:"index"`
|
||||
TableID uint64 `gorm:"index"`
|
||||
UserID uint64 `gorm:"index"`
|
||||
User User `json:"-"`
|
||||
Message string
|
||||
|
|
|
@ -1,130 +0,0 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/encryption"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// DeletedUserMemory table stores security-related information, such as block lists, when a user
|
||||
// deletes their account - so that when they sign up again later under the same username or email
|
||||
// address, this information can be restored and other users on the site can have their block lists
|
||||
// respected.
|
||||
type DeletedUserMemory struct {
|
||||
Username string `gorm:"uniqueIndex:idx_deleted_user_memory"`
|
||||
HashedEmail string `gorm:"uniqueIndex:idx_deleted_user_memory"`
|
||||
Data string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// DeletedUserMemoryData is a JSON serializable struct for data stored in the DeletedUserMemory table.
|
||||
type DeletedUserMemoryData struct {
|
||||
PreviousUsername string
|
||||
BlockingUserIDs []uint64
|
||||
BlockedByUserIDs []uint64
|
||||
}
|
||||
|
||||
// CreateDeletedUserMemory creates the row in the database just before a user account is deleted.
|
||||
func CreateDeletedUserMemory(user *User) error {
|
||||
// Get the user's blocked lists.
|
||||
forward, reverse := GetAllBlockedUserIDs(user)
|
||||
|
||||
// Store the memory.
|
||||
data := DeletedUserMemoryData{
|
||||
PreviousUsername: user.Username,
|
||||
BlockingUserIDs: forward,
|
||||
BlockedByUserIDs: reverse,
|
||||
}
|
||||
bin, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("JSON marshal: %s", err)
|
||||
}
|
||||
|
||||
// Upsert the mute.
|
||||
m := &DeletedUserMemory{
|
||||
Username: user.Username,
|
||||
HashedEmail: string(encryption.Hash([]byte(user.Email))),
|
||||
Data: string(bin),
|
||||
}
|
||||
res := DB.Model(&DeletedUserMemory{}).Clauses(
|
||||
clause.OnConflict{
|
||||
Columns: []clause.Column{
|
||||
{Name: "username"},
|
||||
{Name: "hashed_email"},
|
||||
},
|
||||
UpdateAll: true,
|
||||
},
|
||||
).Create(m)
|
||||
return res.Error
|
||||
}
|
||||
|
||||
// RestoreDeletedUserMemory checks for remembered data and will restore and clear the memory if found.
|
||||
func RestoreDeletedUserMemory(user *User) error {
|
||||
// Anything stored?
|
||||
var (
|
||||
m *DeletedUserMemory
|
||||
hashedEmail = string(encryption.Hash([]byte(user.Email)))
|
||||
err = DB.Model(&DeletedUserMemory{}).Where(
|
||||
"username = ? OR hashed_email = ?",
|
||||
user.Username,
|
||||
hashedEmail,
|
||||
).First(&m).Error
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the remembered payload.
|
||||
var data DeletedUserMemoryData
|
||||
err = json.Unmarshal([]byte(m.Data), &data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("RestoreDeletedUserMemory: JSON unmarshal: %s", err)
|
||||
}
|
||||
|
||||
// Bulk restore the user's block list.
|
||||
blocks, err := BulkRestoreBlockedUserIDs(user, data.BlockingUserIDs, data.BlockedByUserIDs)
|
||||
if err != nil {
|
||||
log.Error("BulkRestoreBlockedUserIDs(%s): %s", user.Username, err)
|
||||
}
|
||||
|
||||
// If any blocks were added, notify the admin that the user has returned - for visibility
|
||||
// and to detect any mistakes.
|
||||
if blocks > 0 {
|
||||
fb := &Feedback{
|
||||
Intent: "report",
|
||||
Subject: "A deleted user has returned",
|
||||
UserID: user.ID,
|
||||
TableName: "users",
|
||||
TableID: user.ID,
|
||||
Message: fmt.Sprintf(
|
||||
"The username **@%s**, who was previously deleted, has signed up a new account "+
|
||||
"with the same username or e-mail address.\n\n"+
|
||||
"Their previous username was **@%s** when they last deleted their account.\n\n"+
|
||||
"Their block lists from when they deleted their old account have been restored:\n\n"+
|
||||
"* Forward list: %d\n* Reverse list: %d",
|
||||
user.Username,
|
||||
data.PreviousUsername,
|
||||
len(data.BlockingUserIDs),
|
||||
len(data.BlockedByUserIDs),
|
||||
),
|
||||
}
|
||||
|
||||
// Save the feedback.
|
||||
if err := CreateFeedback(fb); err != nil {
|
||||
log.Error("Couldn't save feedback from user recovering their deleted account: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the stored user memory.
|
||||
return DB.Where(
|
||||
"username = ? OR hashed_email = ?",
|
||||
user.Username,
|
||||
hashedEmail,
|
||||
).Delete(&DeletedUserMemory{}).Error
|
||||
}
|
|
@ -13,12 +13,6 @@ import (
|
|||
func DeleteUser(user *models.User) error {
|
||||
log.Error("BEGIN DeleteUser(%d, %s)", user.ID, user.Username)
|
||||
|
||||
// Store the user's block lists in case they come back soon under the same email address
|
||||
// or username.
|
||||
if err := models.CreateDeletedUserMemory(user); err != nil {
|
||||
log.Error("DeleteUser(%s): CreateDeletedUserMemory: %s", user.Username, err)
|
||||
}
|
||||
|
||||
// Clear their history on the chat room.
|
||||
go func() {
|
||||
i, err := chat.EraseChatHistory(user.Username)
|
||||
|
@ -57,7 +51,6 @@ func DeleteUser(user *models.User) error {
|
|||
{"Messages", DeleteUserMessages},
|
||||
{"Friends", DeleteFriends},
|
||||
{"Blocks", DeleteBlocks},
|
||||
{"MutedUsers", DeleteMutedUsers},
|
||||
{"Feedbacks", DeleteFeedbacks},
|
||||
{"Two Factor", DeleteTwoFactor},
|
||||
{"Profile Fields", DeleteProfile},
|
||||
|
@ -66,7 +59,6 @@ func DeleteUser(user *models.User) error {
|
|||
{"IP Addresses", DeleteIPAddresses},
|
||||
{"Push Notifications", DeletePushNotifications},
|
||||
{"Forum Memberships", DeleteForumMemberships},
|
||||
{"Usage Statistics", DeleteUsageStatistics},
|
||||
}
|
||||
for _, item := range todo {
|
||||
if err := item.Fn(user.ID); err != nil {
|
||||
|
@ -234,16 +226,6 @@ func DeleteBlocks(userID uint64) error {
|
|||
return result.Error
|
||||
}
|
||||
|
||||
// DeleteMutedUsers scrubs data for deleting a user.
|
||||
func DeleteMutedUsers(userID uint64) error {
|
||||
log.Error("DeleteUser: DeleteMutedUsers(%d)", userID)
|
||||
result := models.DB.Where(
|
||||
"source_user_id = ? OR target_user_id = ?",
|
||||
userID, userID,
|
||||
).Delete(&models.MutedUser{})
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// DeleteFeedbacks scrubs data for deleting a user.
|
||||
func DeleteFeedbacks(userID uint64) error {
|
||||
log.Error("DeleteUser: DeleteFeedbacks(%d)", userID)
|
||||
|
@ -424,13 +406,3 @@ func DeleteForumMemberships(userID uint64) error {
|
|||
).Delete(&models.ForumMembership{})
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// DeleteUsageStatistics scrubs data for deleting a user.
|
||||
func DeleteUsageStatistics(userID uint64) error {
|
||||
log.Error("DeleteUser: DeleteUsageStatistics(%d)", userID)
|
||||
result := models.DB.Where(
|
||||
"user_id = ?",
|
||||
userID,
|
||||
).Delete(&models.UsageStatistic{})
|
||||
return result.Error
|
||||
}
|
||||
|
|
|
@ -1,196 +0,0 @@
|
|||
// Package demographic handles periodic report pulling for high level website statistics.
|
||||
//
|
||||
// It powers the home page and insights page, where a prospective new user can get a peek inside
|
||||
// the website to see the split between regular vs. explicit content and membership statistics.
|
||||
//
|
||||
// These database queries could get slow so the demographics are pulled and cached in this package.
|
||||
package demographic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/utility"
|
||||
)
|
||||
|
||||
// Demographic is the top level container struct with all the insights needed for front-end display.
|
||||
type Demographic struct {
|
||||
Computed bool
|
||||
LastUpdated time.Time
|
||||
Photo Photo
|
||||
People People
|
||||
}
|
||||
|
||||
// Photo statistics show the split between explicit and non-explicit content.
|
||||
type Photo struct {
|
||||
Total int64
|
||||
NonExplicit int64
|
||||
Explicit int64
|
||||
}
|
||||
|
||||
// People statistics.
|
||||
type People struct {
|
||||
Total int64
|
||||
ExplicitOptIn int64
|
||||
ExplicitPhoto int64
|
||||
ByAgeRange map[string]int64
|
||||
ByGender map[string]int64
|
||||
ByOrientation map[string]int64
|
||||
}
|
||||
|
||||
// MemberDemographic of members.
|
||||
type MemberDemographic struct {
|
||||
Label string // e.g. age range "18-25" or gender
|
||||
Count int64
|
||||
Percent string
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic calculation methods on the above types (percentages, etc.)
|
||||
*/
|
||||
|
||||
func (d Demographic) PrettyPrint() string {
|
||||
b, err := json.MarshalIndent(d, "", "\t")
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (p Photo) PercentExplicit() string {
|
||||
if p.Total == 0 {
|
||||
return "0"
|
||||
}
|
||||
return utility.FormatFloatToPrecision((float64(p.Explicit)/float64(p.Total))*100, 1)
|
||||
}
|
||||
|
||||
func (p Photo) PercentNonExplicit() string {
|
||||
if p.Total == 0 {
|
||||
return "0"
|
||||
}
|
||||
return utility.FormatFloatToPrecision((float64(p.NonExplicit)/float64(p.Total))*100, 1)
|
||||
}
|
||||
|
||||
func (p People) PercentExplicit() string {
|
||||
if p.Total == 0 {
|
||||
return "0"
|
||||
}
|
||||
return utility.FormatFloatToPrecision((float64(p.ExplicitOptIn)/float64(p.Total))*100, 1)
|
||||
}
|
||||
|
||||
func (p People) PercentExplicitPhoto() string {
|
||||
if p.Total == 0 {
|
||||
return "0"
|
||||
}
|
||||
return utility.FormatFloatToPrecision((float64(p.ExplicitPhoto)/float64(p.Total))*100, 1)
|
||||
}
|
||||
|
||||
func (p People) IterAgeRanges() []MemberDemographic {
|
||||
var (
|
||||
result = []MemberDemographic{}
|
||||
values = []string{}
|
||||
unique = map[string]struct{}{}
|
||||
)
|
||||
|
||||
for age := range p.ByAgeRange {
|
||||
if _, ok := unique[age]; !ok {
|
||||
values = append(values, age)
|
||||
}
|
||||
unique[age] = struct{}{}
|
||||
}
|
||||
|
||||
sort.Strings(values)
|
||||
for _, age := range values {
|
||||
var (
|
||||
count = p.ByAgeRange[age]
|
||||
pct float64
|
||||
)
|
||||
if p.Total > 0 {
|
||||
pct = ((float64(count) / float64(p.Total)) * 100)
|
||||
}
|
||||
|
||||
result = append(result, MemberDemographic{
|
||||
Label: age,
|
||||
Count: p.ByAgeRange[age],
|
||||
Percent: utility.FormatFloatToPrecision(pct, 1),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (p People) IterGenders() []MemberDemographic {
|
||||
var (
|
||||
result = []MemberDemographic{}
|
||||
values = append(config.Gender, "")
|
||||
unique = map[string]struct{}{}
|
||||
)
|
||||
|
||||
for _, option := range values {
|
||||
unique[option] = struct{}{}
|
||||
}
|
||||
|
||||
for gender := range p.ByGender {
|
||||
if _, ok := unique[gender]; !ok {
|
||||
values = append(values, gender)
|
||||
unique[gender] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, gender := range values {
|
||||
var (
|
||||
count = p.ByGender[gender]
|
||||
pct float64
|
||||
)
|
||||
if p.Total > 0 {
|
||||
pct = ((float64(count) / float64(p.Total)) * 100)
|
||||
}
|
||||
|
||||
result = append(result, MemberDemographic{
|
||||
Label: gender,
|
||||
Count: p.ByGender[gender],
|
||||
Percent: utility.FormatFloatToPrecision(pct, 1),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (p People) IterOrientations() []MemberDemographic {
|
||||
var (
|
||||
result = []MemberDemographic{}
|
||||
values = append(config.Orientation, "")
|
||||
unique = map[string]struct{}{}
|
||||
)
|
||||
|
||||
for _, option := range values {
|
||||
unique[option] = struct{}{}
|
||||
}
|
||||
|
||||
for orientation := range p.ByOrientation {
|
||||
if _, ok := unique[orientation]; !ok {
|
||||
values = append(values, orientation)
|
||||
unique[orientation] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, gender := range values {
|
||||
var (
|
||||
count = p.ByOrientation[gender]
|
||||
pct float64
|
||||
)
|
||||
if p.Total > 0 {
|
||||
pct = ((float64(count) / float64(p.Total)) * 100)
|
||||
}
|
||||
|
||||
result = append(result, MemberDemographic{
|
||||
Label: gender,
|
||||
Count: p.ByOrientation[gender],
|
||||
Percent: utility.FormatFloatToPrecision(pct, 1),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
|
@ -1,302 +0,0 @@
|
|||
package demographic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
)
|
||||
|
||||
// Cached statistics (in case the queries are heavy to hit too often).
|
||||
var (
|
||||
cachedDemographic Demographic
|
||||
cacheMu sync.Mutex
|
||||
)
|
||||
|
||||
// Get the current cached demographics result.
|
||||
func Get() (Demographic, error) {
|
||||
// Do we have the results cached?
|
||||
var result = cachedDemographic
|
||||
if !result.Computed || time.Since(result.LastUpdated) > config.DemographicsCacheTTL {
|
||||
cacheMu.Lock()
|
||||
defer cacheMu.Unlock()
|
||||
|
||||
// If we have a race of threads: e.g. one request is pulling the stats and the second is locked.
|
||||
// Check if we have an updated result from the first thread.
|
||||
if time.Since(cachedDemographic.LastUpdated) < config.DemographicsCacheTTL {
|
||||
return cachedDemographic, nil
|
||||
}
|
||||
|
||||
// Get the latest.
|
||||
res, err := Generate()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
cachedDemographic = res
|
||||
}
|
||||
|
||||
return cachedDemographic, nil
|
||||
}
|
||||
|
||||
// Refresh the demographics cache, pulling fresh results from the database every time.
|
||||
func Refresh() (Demographic, error) {
|
||||
cacheMu.Lock()
|
||||
cachedDemographic = Demographic{}
|
||||
cacheMu.Unlock()
|
||||
return Get()
|
||||
}
|
||||
|
||||
// Generate the demographics result.
|
||||
func Generate() (Demographic, error) {
|
||||
if !config.Current.Database.IsPostgres {
|
||||
return cachedDemographic, errors.New("this feature requires a PostgreSQL database")
|
||||
}
|
||||
|
||||
result := Demographic{
|
||||
Computed: true,
|
||||
LastUpdated: time.Now(),
|
||||
Photo: PhotoStatistics(),
|
||||
People: PeopleStatistics(),
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PeopleStatistics pulls various metrics about users of the website.
|
||||
func PeopleStatistics() People {
|
||||
var result = People{
|
||||
ByAgeRange: map[string]int64{},
|
||||
ByGender: map[string]int64{"": 0},
|
||||
ByOrientation: map[string]int64{"": 0},
|
||||
}
|
||||
|
||||
type record struct {
|
||||
MetricType string
|
||||
MetricValue string
|
||||
MetricCount int64
|
||||
}
|
||||
var records []record
|
||||
res := models.DB.Raw(`
|
||||
-- Users who opt in/out of explicit content
|
||||
WITH subquery_explicit AS (
|
||||
SELECT
|
||||
SUM(CASE WHEN explicit IS TRUE THEN 1 ELSE 0 END) AS explicit_count,
|
||||
SUM(CASE WHEN explicit IS NOT TRUE THEN 1 ELSE 0 END) AS non_explicit_count
|
||||
FROM users
|
||||
WHERE users.status = 'active'
|
||||
AND users.certified IS TRUE
|
||||
),
|
||||
|
||||
-- Users who share at least one explicit photo on public
|
||||
subquery_explicit_photo AS (
|
||||
SELECT
|
||||
COUNT(*) AS user_count
|
||||
FROM users
|
||||
WHERE users.status = 'active'
|
||||
AND users.certified IS TRUE
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM photos
|
||||
WHERE photos.user_id = users.id
|
||||
AND photos.explicit IS TRUE
|
||||
AND photos.visibility = 'public'
|
||||
)
|
||||
),
|
||||
|
||||
-- User counts by age
|
||||
subquery_ages AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 0 AND 25 THEN '18-25'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 26 and 35 THEN '26-35'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 36 and 45 THEN '36-45'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 46 and 55 THEN '46-55'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 56 and 65 THEN '56-65'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 66 and 75 THEN '66-75'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 76 and 85 THEN '76-85'
|
||||
ELSE '86+'
|
||||
END AS age_range,
|
||||
COUNT(*) AS user_count
|
||||
FROM
|
||||
users
|
||||
WHERE users.status = 'active'
|
||||
AND users.certified IS TRUE
|
||||
GROUP BY
|
||||
CASE
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 0 AND 25 THEN '18-25'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 26 and 35 THEN '26-35'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 36 and 45 THEN '36-45'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 46 and 55 THEN '46-55'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 56 and 65 THEN '56-65'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 66 and 75 THEN '66-75'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 76 and 85 THEN '76-85'
|
||||
ELSE '86+'
|
||||
END
|
||||
),
|
||||
|
||||
-- User counts by gender
|
||||
subquery_gender AS (
|
||||
SELECT
|
||||
profile_fields.value AS gender,
|
||||
COUNT(*) AS user_count
|
||||
FROM users
|
||||
JOIN profile_fields ON profile_fields.user_id = users.id
|
||||
WHERE users.status = 'active'
|
||||
AND users.certified IS TRUE
|
||||
AND profile_fields.name = 'gender'
|
||||
GROUP BY profile_fields.value
|
||||
),
|
||||
|
||||
-- User counts by orientation
|
||||
subquery_orientation AS (
|
||||
SELECT
|
||||
profile_fields.value AS orientation,
|
||||
COUNT(*) AS user_count
|
||||
FROM users
|
||||
JOIN profile_fields ON profile_fields.user_id = users.id
|
||||
WHERE users.status = 'active'
|
||||
AND users.certified IS TRUE
|
||||
AND profile_fields.name = 'orientation'
|
||||
GROUP BY profile_fields.value
|
||||
)
|
||||
|
||||
SELECT
|
||||
'ExplicitCount' AS metric_type,
|
||||
'explicit' AS metric_value,
|
||||
explicit_count AS metric_count
|
||||
FROM subquery_explicit
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'ExplicitPhotoCount' AS metric_type,
|
||||
'count' AS metric_value,
|
||||
user_count AS metric_count
|
||||
FROM subquery_explicit_photo
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'ExplicitCount' AS metric_type,
|
||||
'non_explicit' AS metric_value,
|
||||
non_explicit_count AS metric_count
|
||||
FROM subquery_explicit
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'AgeCounts' AS metric_type,
|
||||
age_range AS metric_value,
|
||||
user_count AS metric_count
|
||||
FROM subquery_ages
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'GenderCount' AS metric_type,
|
||||
gender AS metric_value,
|
||||
user_count AS metric_count
|
||||
FROM subquery_gender
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'OrientationCount' AS metric_type,
|
||||
orientation AS metric_value,
|
||||
user_count AS metric_count
|
||||
FROM subquery_orientation
|
||||
`).Scan(&records)
|
||||
if res.Error != nil {
|
||||
log.Error("PeopleStatistics: %s", res.Error)
|
||||
return result
|
||||
}
|
||||
|
||||
// Ingest the records.
|
||||
var (
|
||||
totalWithAge int64 // will be the total count of users since age is required
|
||||
totalWithGender int64
|
||||
totalWithOrientation int64
|
||||
)
|
||||
for _, row := range records {
|
||||
switch row.MetricType {
|
||||
case "ExplicitCount":
|
||||
result.Total += row.MetricCount
|
||||
if row.MetricValue == "explicit" {
|
||||
result.ExplicitOptIn = row.MetricCount
|
||||
}
|
||||
case "ExplicitPhotoCount":
|
||||
result.ExplicitPhoto = row.MetricCount
|
||||
case "AgeCounts":
|
||||
if _, ok := result.ByAgeRange[row.MetricValue]; !ok {
|
||||
result.ByAgeRange[row.MetricValue] = 0
|
||||
}
|
||||
result.ByAgeRange[row.MetricValue] += row.MetricCount
|
||||
totalWithAge += row.MetricCount
|
||||
case "GenderCount":
|
||||
if _, ok := result.ByGender[row.MetricValue]; !ok {
|
||||
result.ByGender[row.MetricValue] = 0
|
||||
}
|
||||
result.ByGender[row.MetricValue] += row.MetricCount
|
||||
totalWithGender += row.MetricCount
|
||||
case "OrientationCount":
|
||||
if _, ok := result.ByOrientation[row.MetricValue]; !ok {
|
||||
result.ByOrientation[row.MetricValue] = 0
|
||||
}
|
||||
result.ByOrientation[row.MetricValue] += row.MetricCount
|
||||
totalWithOrientation += row.MetricCount
|
||||
}
|
||||
}
|
||||
|
||||
// Gender and Orientation: pad out the "no answer" selection with the count of users
|
||||
// who had no profile_fields stored in the DB at all.
|
||||
result.ByOrientation[""] += (totalWithAge - totalWithOrientation)
|
||||
result.ByGender[""] += (totalWithAge - totalWithGender)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// PhotoStatistics gets info about photo usage on the website.
|
||||
//
|
||||
// Counts of Explicit vs. Non-Explicit photos.
|
||||
func PhotoStatistics() Photo {
|
||||
var result Photo
|
||||
type record struct {
|
||||
Explicit bool
|
||||
C int64
|
||||
}
|
||||
var records []record
|
||||
|
||||
res := models.DB.Raw(`
|
||||
SELECT
|
||||
photos.explicit,
|
||||
count(photos.id) AS c
|
||||
FROM
|
||||
photos
|
||||
JOIN users ON (photos.user_id = users.id)
|
||||
WHERE photos.visibility = 'public'
|
||||
AND photos.gallery IS TRUE
|
||||
AND users.certified IS TRUE
|
||||
AND users.status = 'active'
|
||||
GROUP BY photos.explicit
|
||||
ORDER BY c DESC
|
||||
`).Scan(&records)
|
||||
if res.Error != nil {
|
||||
log.Error("PhotoStatistics: %s", res.Error)
|
||||
return result
|
||||
}
|
||||
|
||||
for _, row := range records {
|
||||
result.Total += row.C
|
||||
if row.Explicit {
|
||||
result.Explicit += row.C
|
||||
} else {
|
||||
result.NonExplicit += row.C
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
|
@ -19,34 +19,30 @@ func ExportModels(zw *zip.Writer, user *models.User) error {
|
|||
// List of tables to export. Keep the ordering in sync with
|
||||
// the AutoMigrate() calls in ../models.go
|
||||
var todo = []task{
|
||||
// Note: AdminGroup info is eager-loaded in User export
|
||||
{"Block", ExportBlockTable},
|
||||
{"CertificationPhoto", ExportCertificationPhotoTable},
|
||||
{"ChangeLog", ExportChangeLogTable},
|
||||
{"Comment", ExportCommentTable},
|
||||
{"CommentPhoto", ExportCommentPhotoTable},
|
||||
{"Feedback", ExportFeedbackTable},
|
||||
{"ForumMembership", ExportForumMembershipTable},
|
||||
{"Friend", ExportFriendTable},
|
||||
{"Forum", ExportForumTable},
|
||||
{"IPAddress", ExportIPAddressTable},
|
||||
{"Like", ExportLikeTable},
|
||||
{"Message", ExportMessageTable},
|
||||
{"MutedUser", ExportMutedUserTable},
|
||||
{"Notification", ExportNotificationTable},
|
||||
{"User", ExportUserTable},
|
||||
{"ProfileField", ExportProfileFieldTable},
|
||||
{"Photo", ExportPhotoTable},
|
||||
{"PrivatePhoto", ExportPrivatePhotoTable},
|
||||
{"CertificationPhoto", ExportCertificationPhotoTable},
|
||||
{"Message", ExportMessageTable},
|
||||
{"Friend", ExportFriendTable},
|
||||
{"Block", ExportBlockTable},
|
||||
{"Feedback", ExportFeedbackTable},
|
||||
{"Forum", ExportForumTable},
|
||||
{"Thread", ExportThreadTable},
|
||||
{"Comment", ExportCommentTable},
|
||||
{"Like", ExportLikeTable},
|
||||
{"Notification", ExportNotificationTable},
|
||||
{"Subscription", ExportSubscriptionTable},
|
||||
{"CommentPhoto", ExportCommentPhotoTable},
|
||||
// Note: Poll table is eager-loaded in Thread export
|
||||
{"PollVote", ExportPollVoteTable},
|
||||
{"PrivatePhoto", ExportPrivatePhotoTable},
|
||||
{"PushNotification", ExportPushNotificationTable},
|
||||
{"Subscription", ExportSubscriptionTable},
|
||||
{"Thread", ExportThreadTable},
|
||||
{"TwoFactor", ExportTwoFactorTable},
|
||||
{"UsageStatistic", ExportUsageStatisticTable},
|
||||
{"User", ExportUserTable},
|
||||
// Note: AdminGroup info is eager-loaded in User export
|
||||
{"UserLocation", ExportUserLocationTable},
|
||||
{"UserNote", ExportUserNoteTable},
|
||||
{"ChangeLog", ExportChangeLogTable},
|
||||
{"TwoFactor", ExportTwoFactorTable},
|
||||
{"IPAddress", ExportIPAddressTable},
|
||||
}
|
||||
for _, item := range todo {
|
||||
log.Info("Exporting data model: %s", item.Step)
|
||||
|
@ -190,21 +186,6 @@ func ExportBlockTable(zw *zip.Writer, user *models.User) error {
|
|||
return ZipJson(zw, "blocks.json", items)
|
||||
}
|
||||
|
||||
func ExportMutedUserTable(zw *zip.Writer, user *models.User) error {
|
||||
var (
|
||||
items = []*models.MutedUser{}
|
||||
query = models.DB.Model(&models.MutedUser{}).Where(
|
||||
"source_user_id = ?",
|
||||
user.ID,
|
||||
).Find(&items)
|
||||
)
|
||||
if query.Error != nil {
|
||||
return query.Error
|
||||
}
|
||||
|
||||
return ZipJson(zw, "muted_users.json", items)
|
||||
}
|
||||
|
||||
func ExportFeedbackTable(zw *zip.Writer, user *models.User) error {
|
||||
var (
|
||||
items = []*models.Feedback{}
|
||||
|
@ -463,48 +444,3 @@ func ExportIPAddressTable(zw *zip.Writer, user *models.User) error {
|
|||
|
||||
return ZipJson(zw, "ip_addresses.json", items)
|
||||
}
|
||||
|
||||
func ExportForumMembershipTable(zw *zip.Writer, user *models.User) error {
|
||||
var (
|
||||
items = []*models.ForumMembership{}
|
||||
query = models.DB.Model(&models.ForumMembership{}).Where(
|
||||
"user_id = ?",
|
||||
user.ID,
|
||||
).Find(&items)
|
||||
)
|
||||
if query.Error != nil {
|
||||
return query.Error
|
||||
}
|
||||
|
||||
return ZipJson(zw, "forum_memberships.json", items)
|
||||
}
|
||||
|
||||
func ExportPushNotificationTable(zw *zip.Writer, user *models.User) error {
|
||||
var (
|
||||
items = []*models.PushNotification{}
|
||||
query = models.DB.Model(&models.PushNotification{}).Where(
|
||||
"user_id = ?",
|
||||
user.ID,
|
||||
).Find(&items)
|
||||
)
|
||||
if query.Error != nil {
|
||||
return query.Error
|
||||
}
|
||||
|
||||
return ZipJson(zw, "push_notifications.json", items)
|
||||
}
|
||||
|
||||
func ExportUsageStatisticTable(zw *zip.Writer, user *models.User) error {
|
||||
var (
|
||||
items = []*models.UsageStatistic{}
|
||||
query = models.DB.Model(&models.UsageStatistic{}).Where(
|
||||
"user_id = ?",
|
||||
user.ID,
|
||||
).Find(&items)
|
||||
)
|
||||
if query.Error != nil {
|
||||
return query.Error
|
||||
}
|
||||
|
||||
return ZipJson(zw, "usage_statistics.json", items)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
@ -12,7 +11,6 @@ import (
|
|||
type Feedback struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
UserID uint64 `gorm:"index"` // if logged-in user posted this
|
||||
AboutUserID uint64 // associated 'about' user (e.g., owner of a reported photo)
|
||||
Acknowledged bool `gorm:"index"` // admin dashboard "read" status
|
||||
Intent string
|
||||
Subject string
|
||||
|
@ -47,7 +45,7 @@ func CountUnreadFeedback() int64 {
|
|||
}
|
||||
|
||||
// PaginateFeedback
|
||||
func PaginateFeedback(acknowledged bool, intent, subject string, search *Search, pager *Pagination) ([]*Feedback, error) {
|
||||
func PaginateFeedback(acknowledged bool, intent string, pager *Pagination) ([]*Feedback, error) {
|
||||
var (
|
||||
fb = []*Feedback{}
|
||||
wheres = []string{}
|
||||
|
@ -62,23 +60,6 @@ func PaginateFeedback(acknowledged bool, intent, subject string, search *Search,
|
|||
placeholders = append(placeholders, intent)
|
||||
}
|
||||
|
||||
if subject != "" {
|
||||
wheres = append(wheres, "subject = ?")
|
||||
placeholders = append(placeholders, subject)
|
||||
}
|
||||
|
||||
// Search terms.
|
||||
for _, term := range search.Includes {
|
||||
var ilike = "%" + strings.ToLower(term) + "%"
|
||||
wheres = append(wheres, "message ILIKE ?")
|
||||
placeholders = append(placeholders, ilike)
|
||||
}
|
||||
for _, term := range search.Excludes {
|
||||
var ilike = "%" + strings.ToLower(term) + "%"
|
||||
wheres = append(wheres, "message NOT ILIKE ?")
|
||||
placeholders = append(placeholders, ilike)
|
||||
}
|
||||
|
||||
query := DB.Where(
|
||||
strings.Join(wheres, " AND "),
|
||||
placeholders...,
|
||||
|
@ -100,49 +81,19 @@ func PaginateFeedback(acknowledged bool, intent, subject string, search *Search,
|
|||
// It returns reports where table_name=users and their user ID, or where table_name=photos and about any
|
||||
// of their current photo IDs. Additionally, it will look for chat room reports which were about their
|
||||
// username.
|
||||
//
|
||||
// The 'show' parameter applies some basic filter choices:
|
||||
//
|
||||
// - Blank string (default) = all reports From or About this user
|
||||
// - "about" = all reports About this user (by table_name=users table_id=userID, or table_name=photos
|
||||
// for any of their existing photo IDs)
|
||||
// - "from" = all reports From this user (where reporting user_id is the user's ID)
|
||||
// - "fuzzy" = fuzzy full text search on all reports that contain the user's username.
|
||||
func PaginateFeedbackAboutUser(user *User, show string, pager *Pagination) ([]*Feedback, error) {
|
||||
func PaginateFeedbackAboutUser(user *User, pager *Pagination) ([]*Feedback, error) {
|
||||
var (
|
||||
fb = []*Feedback{}
|
||||
photoIDs, _ = user.AllPhotoIDs()
|
||||
wheres = []string{}
|
||||
placeholders = []interface{}{}
|
||||
like = "%" + user.Username + "%"
|
||||
)
|
||||
|
||||
// How to apply the search filters?
|
||||
switch show {
|
||||
case "about":
|
||||
wheres = append(wheres, `
|
||||
about_user_id = ? OR
|
||||
(table_name = 'users' AND table_id = ?) OR
|
||||
(table_name = 'photos' AND table_id IN ?)
|
||||
`)
|
||||
placeholders = append(placeholders, user.ID, user.ID, photoIDs)
|
||||
case "from":
|
||||
wheres = append(wheres, "user_id = ?")
|
||||
placeholders = append(placeholders, user.ID)
|
||||
case "fuzzy":
|
||||
wheres = append(wheres, "message LIKE ?")
|
||||
placeholders = append(placeholders, like)
|
||||
default:
|
||||
// Default=everything.
|
||||
wheres = append(wheres, `
|
||||
user_id = ? OR
|
||||
about_user_id = ? OR
|
||||
(table_name = 'users' AND table_id = ?) OR
|
||||
(table_name = 'photos' AND table_id IN ?) OR
|
||||
message LIKE ?
|
||||
`)
|
||||
placeholders = append(placeholders, user.ID, user.ID, user.ID, photoIDs, like)
|
||||
}
|
||||
wheres = append(wheres, `
|
||||
(table_name = 'users' AND table_id = ?) OR
|
||||
(table_name = 'photos' AND table_id IN ?)
|
||||
`)
|
||||
placeholders = append(placeholders, user.ID, photoIDs)
|
||||
|
||||
query := DB.Where(
|
||||
strings.Join(wheres, " AND "),
|
||||
|
@ -160,22 +111,6 @@ func PaginateFeedbackAboutUser(user *User, show string, pager *Pagination) ([]*F
|
|||
return fb, result.Error
|
||||
}
|
||||
|
||||
// DistinctFeedbackSubjects returns the distinct subjects on feedback & reports.
|
||||
func DistinctFeedbackSubjects() []string {
|
||||
var results = []string{}
|
||||
query := DB.Model(&Feedback{}).
|
||||
Select("DISTINCT feedbacks.subject").
|
||||
Group("feedbacks.subject").
|
||||
Find(&results)
|
||||
if query.Error != nil {
|
||||
log.Error("DistinctFeedbackSubjects: %s", query.Error)
|
||||
return nil
|
||||
}
|
||||
|
||||
sort.Strings(results)
|
||||
return results
|
||||
}
|
||||
|
||||
// CreateFeedback saves a new Feedback row to the DB.
|
||||
func CreateFeedback(fb *Feedback) error {
|
||||
result := DB.Create(fb)
|
||||
|
|
|
@ -140,10 +140,7 @@ func PaginateForums(user *User, categories []string, search *Search, subscribed
|
|||
WHERE user_id = ?
|
||||
AND forum_id = forums.id
|
||||
)
|
||||
OR (
|
||||
forums.owner_id = ?
|
||||
AND (forums.category = '' OR forums.category IS NULL)
|
||||
)
|
||||
OR forums.owner_id = ?
|
||||
`)
|
||||
placeholders = append(placeholders, user.ID, user.ID)
|
||||
}
|
||||
|
|
|
@ -11,8 +11,8 @@ import (
|
|||
type Like struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
UserID uint64 `gorm:"index"` // who it belongs to
|
||||
TableName string `gorm:"index:idx_likes_composite"`
|
||||
TableID uint64 `gorm:"index:idx_likes_composite"`
|
||||
TableName string `gorm:"index"`
|
||||
TableID uint64 `gorm:"index"`
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
|
|
@ -169,16 +169,6 @@ func HasMessageThread(a, b *User) (uint64, bool) {
|
|||
return 0, false
|
||||
}
|
||||
|
||||
// HasSentAMessage tells if the source user has sent a DM to the target user.
|
||||
func HasSentAMessage(sourceUser, targetUser *User) bool {
|
||||
var count int64
|
||||
DB.Model(&Message{}).Where(
|
||||
"source_user_id = ? AND target_user_id = ?",
|
||||
sourceUser.ID, targetUser.ID,
|
||||
).Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// DeleteMessageThread removes all message history between two people.
|
||||
func DeleteMessageThread(message *Message) error {
|
||||
return DB.Where(
|
||||
|
|
|
@ -24,7 +24,6 @@ func AutoMigrate() {
|
|||
&IPAddress{}, // ✔
|
||||
&Like{}, // ✔
|
||||
&Message{}, // ✔
|
||||
&MutedUser{}, // ✔
|
||||
&Notification{}, // ✔
|
||||
&ProfileField{}, // ✔
|
||||
&Photo{}, // ✔
|
||||
|
@ -32,17 +31,15 @@ func AutoMigrate() {
|
|||
&Poll{}, // vacuum script cleans up orphaned polls
|
||||
&PrivatePhoto{}, // ✔
|
||||
&PushNotification{}, // ✔
|
||||
&Subscription{}, // ✔
|
||||
&Thread{}, // ✔
|
||||
&TwoFactor{}, // ✔
|
||||
&UsageStatistic{}, // ✔
|
||||
&Subscription{}, // ✔
|
||||
&User{}, // ✔
|
||||
&UserLocation{}, // ✔
|
||||
&UserNote{}, // ✔
|
||||
|
||||
// Non-user or persistent data.
|
||||
&AdminScope{},
|
||||
&DeletedUserMemory{},
|
||||
&Forum{},
|
||||
|
||||
// Vendor/misc data.
|
||||
|
|
|
@ -1,103 +0,0 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// MutedUser table, for users to mute one another's Site Gallery photos and similar.
|
||||
type MutedUser struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
SourceUserID uint64 `gorm:"uniqueIndex:idx_muted_user"`
|
||||
TargetUserID uint64 `gorm:"uniqueIndex:idx_muted_user"`
|
||||
Context MutedUserContext `gorm:"uniqueIndex:idx_muted_user"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type MutedUserContext string
|
||||
|
||||
// Context options for MutedUser to specify what is being muted.
|
||||
const (
|
||||
MutedUserContextSiteGallery MutedUserContext = "site_gallery" // hide a user's photos from the Site Gallery.
|
||||
)
|
||||
|
||||
// IsValidMuteUserContext validates acceptable options for muting users.
|
||||
func IsValidMuteUserContext(ctx MutedUserContext) bool {
|
||||
return ctx == MutedUserContextSiteGallery
|
||||
}
|
||||
|
||||
// AddMutedUser is sourceUserId adding targetUserId to their mute list under the given context.
|
||||
func AddMutedUser(sourceUserID, targetUserID uint64, ctx MutedUserContext) error {
|
||||
m := &MutedUser{
|
||||
SourceUserID: sourceUserID,
|
||||
TargetUserID: targetUserID,
|
||||
Context: ctx,
|
||||
}
|
||||
|
||||
// Upsert the mute.
|
||||
res := DB.Model(&MutedUser{}).Clauses(
|
||||
clause.OnConflict{
|
||||
Columns: []clause.Column{
|
||||
{Name: "source_user_id"},
|
||||
{Name: "target_user_id"},
|
||||
{Name: "context"},
|
||||
},
|
||||
DoNothing: true,
|
||||
},
|
||||
).Create(m)
|
||||
return res.Error
|
||||
}
|
||||
|
||||
// MutedUserIDs returns all user IDs Muted by the user.
|
||||
func MutedUserIDs(user *User, context MutedUserContext) []uint64 {
|
||||
var (
|
||||
ms = []*MutedUser{}
|
||||
userIDs = []uint64{}
|
||||
)
|
||||
DB.Where("source_user_id = ? AND context = ?", user.ID, context).Find(&ms)
|
||||
for _, row := range ms {
|
||||
userIDs = append(userIDs, row.TargetUserID)
|
||||
}
|
||||
return userIDs
|
||||
}
|
||||
|
||||
// PaginateMuteList views a user's mute lists.
|
||||
func PaginateMuteList(user *User, pager *Pagination) ([]*User, error) {
|
||||
// We paginate over the MutedUser table.
|
||||
var (
|
||||
ms = []*MutedUser{}
|
||||
userIDs = []uint64{}
|
||||
query *gorm.DB
|
||||
)
|
||||
|
||||
query = DB.Where(
|
||||
"source_user_id = ?",
|
||||
user.ID,
|
||||
)
|
||||
|
||||
query = query.Order(pager.Sort)
|
||||
query.Model(&MutedUser{}).Count(&pager.Total)
|
||||
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&ms)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
// Now of these users get their User objects.
|
||||
for _, b := range ms {
|
||||
userIDs = append(userIDs, b.TargetUserID)
|
||||
}
|
||||
|
||||
return GetUsers(user, userIDs)
|
||||
}
|
||||
|
||||
// RemoveMutedUser clears the muted user row.
|
||||
func RemoveMutedUser(sourceUserID, targetUserID uint64, ctx MutedUserContext) error {
|
||||
result := DB.Where(
|
||||
"source_user_id = ? AND target_user_id = ? AND context = ?",
|
||||
sourceUserID, targetUserID, ctx,
|
||||
).Delete(&MutedUser{})
|
||||
return result.Error
|
||||
}
|
|
@ -46,7 +46,6 @@ const (
|
|||
NotificationPrivatePhoto NotificationType = "private_photo" // private photo grants
|
||||
NotificationNewPhoto NotificationType = "new_photo"
|
||||
NotificationForumModerator NotificationType = "forum_moderator" // appointed as a forum moderator
|
||||
NotificationExplicitPhoto NotificationType = "explicit_photo" // user photo was flagged explicit
|
||||
NotificationCustom NotificationType = "custom" // custom message pushed
|
||||
)
|
||||
|
||||
|
|
|
@ -85,13 +85,7 @@ func (nf NotificationFilter) Query() (where string, placeholders []interface{},
|
|||
types = append(types, NotificationPrivatePhoto)
|
||||
}
|
||||
if nf.Misc {
|
||||
types = append(types,
|
||||
NotificationFriendApproved,
|
||||
NotificationCertApproved,
|
||||
NotificationCertRejected,
|
||||
NotificationExplicitPhoto,
|
||||
NotificationCustom,
|
||||
)
|
||||
types = append(types, NotificationFriendApproved, NotificationCertApproved, NotificationCertRejected, NotificationCustom)
|
||||
}
|
||||
|
||||
return "type IN ?", types, true
|
||||
|
|
|
@ -8,7 +8,6 @@ import (
|
|||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/redis"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
|
@ -22,14 +21,10 @@ type Photo struct {
|
|||
Caption string
|
||||
AltText string
|
||||
Flagged bool // photo has been reported by the community
|
||||
AdminLabel string // admin label(s) on this photo (e.g. verified non-explicit)
|
||||
Visibility PhotoVisibility `gorm:"index"`
|
||||
Gallery bool `gorm:"index"` // photo appears in the public gallery (if public)
|
||||
Explicit bool `gorm:"index"` // is an explicit photo
|
||||
Pinned bool `gorm:"index"` // user pins it to the front of their gallery
|
||||
LikeCount int64 `gorm:"index"` // cache of 'likes' count
|
||||
CommentCount int64 `gorm:"index"` // cache of comments count
|
||||
Views uint64 `gorm:"index"` // view count
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
@ -108,71 +103,6 @@ func GetPhotos(IDs []uint64) (map[uint64]*Photo, error) {
|
|||
return mp, result.Error
|
||||
}
|
||||
|
||||
// CanBeEditedBy checks whether a photo can be edited by the current user.
|
||||
//
|
||||
// Admins with PhotoModerator scope can always edit.
|
||||
func (p *Photo) CanBeEditedBy(currentUser *User) bool {
|
||||
if currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||
return true
|
||||
}
|
||||
|
||||
return p.UserID == currentUser.ID
|
||||
}
|
||||
|
||||
// CanBeSeenBy checks whether a photo can be seen by the current user.
|
||||
//
|
||||
// An admin user with omni photo view permission can always see the photo.
|
||||
//
|
||||
// Note: this function incurs several DB queries to look up the photo's owner and block lists.
|
||||
func (p *Photo) CanBeSeenBy(currentUser *User) (bool, error) {
|
||||
// Admins with photo moderator ability can always see.
|
||||
if currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return p.ShouldBeSeenBy(currentUser)
|
||||
}
|
||||
|
||||
// ShouldBeSeenBy checks whether a photo should be seen by the current user.
|
||||
//
|
||||
// Even if the current user is an admin with photo moderator ability, this function will return
|
||||
// whether the admin 'should' be able to see if not for their admin status. Example: a private
|
||||
// photo may be shown to the admin so they can moderate it, but they shouldn't be able to "like"
|
||||
// it or mark it as "viewed."
|
||||
//
|
||||
// Note: this function incurs several DB queries to look up the photo's owner and block lists.
|
||||
func (p *Photo) ShouldBeSeenBy(currentUser *User) (bool, error) {
|
||||
// Find the photo's owner.
|
||||
user, err := GetUser(p.UserID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var isOwnPhoto = currentUser.ID == user.ID
|
||||
|
||||
// Is either one blocking?
|
||||
if IsBlocking(currentUser.ID, user.ID) {
|
||||
return false, errors.New("is blocking")
|
||||
}
|
||||
|
||||
// Is this user private and we're not friends?
|
||||
var (
|
||||
areFriends = AreFriends(user.ID, currentUser.ID)
|
||||
isPrivate = user.Visibility == UserVisibilityPrivate && !areFriends
|
||||
)
|
||||
if isPrivate && !isOwnPhoto {
|
||||
return false, errors.New("user is private and we aren't friends")
|
||||
}
|
||||
|
||||
// Is this a private photo and are we allowed to see?
|
||||
isGranted := IsPrivateUnlocked(user.ID, currentUser.ID)
|
||||
if p.Visibility == PhotoPrivate && !isGranted && !isOwnPhoto {
|
||||
return false, errors.New("photo is private")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// UserGallery configuration for filtering gallery pages.
|
||||
type UserGallery struct {
|
||||
Explicit string // "", "true", "false"
|
||||
|
@ -222,35 +152,6 @@ func PaginateUserPhotos(userID uint64, conf UserGallery, pager *Pagination) ([]*
|
|||
return p, result.Error
|
||||
}
|
||||
|
||||
// View a photo, incrementing its Views count but not its UpdatedAt.
|
||||
// Debounced with a Redis key.
|
||||
func (p *Photo) View(user *User) error {
|
||||
// The owner of the photo does not count views.
|
||||
if p.UserID == user.ID {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Should the viewer be able to see this, regardless of their admin ability?
|
||||
if ok, err := p.ShouldBeSeenBy(user); !ok {
|
||||
return err
|
||||
}
|
||||
|
||||
// Debounce this.
|
||||
var redisKey = fmt.Sprintf(config.PhotoViewDebounceRedisKey, user.ID, p.ID)
|
||||
if redis.Exists(redisKey) {
|
||||
return nil
|
||||
}
|
||||
redis.Set(redisKey, nil, config.PhotoViewDebounceCooldown)
|
||||
|
||||
return DB.Model(&Photo{}).Where(
|
||||
"id = ?",
|
||||
p.ID,
|
||||
).Updates(map[string]interface{}{
|
||||
"views": p.Views + 1,
|
||||
"updated_at": p.UpdatedAt,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// CountPhotos returns the total number of photos on a user's account.
|
||||
func CountPhotos(userID uint64) int64 {
|
||||
var count int64
|
||||
|
@ -286,69 +187,6 @@ func GetOrphanedPhotos() ([]*Photo, int64, error) {
|
|||
return ps, count, res.Error
|
||||
}
|
||||
|
||||
// HasAdminLabelNonExplicit checks if the non-explicit admin label is applied to this photo.
|
||||
func (p *Photo) HasAdminLabelNonExplicit() bool {
|
||||
return config.HasAdminLabel(config.AdminLabelPhotoNonExplicit, p.AdminLabel)
|
||||
}
|
||||
|
||||
// HasAdminLabelForceExplicit checks if the force-explicit admin label is applied to this photo.
|
||||
func (p *Photo) HasAdminLabelForceExplicit() bool {
|
||||
return config.HasAdminLabel(config.AdminLabelPhotoForceExplicit, p.AdminLabel)
|
||||
}
|
||||
|
||||
// HasAdminLabel checks if the photo has an admin label (for convenient front-end access on the Edit page).
|
||||
func (p *Photo) HasAdminLabel(label string) bool {
|
||||
return config.HasAdminLabel(label, p.AdminLabel)
|
||||
}
|
||||
|
||||
// PhotoMap helps map a set of users to look up by ID.
|
||||
type PhotoMap map[uint64]*Photo
|
||||
|
||||
// MapPhotos looks up a set of photos IDs in bulk and returns a PhotoMap suitable for templates.
|
||||
func MapPhotos(photoIDs []uint64) (PhotoMap, error) {
|
||||
var (
|
||||
photoMap = PhotoMap{}
|
||||
set = map[uint64]interface{}{}
|
||||
distinct = []uint64{}
|
||||
)
|
||||
|
||||
// Uniqueify the IDs.
|
||||
for _, uid := range photoIDs {
|
||||
if _, ok := set[uid]; ok {
|
||||
continue
|
||||
}
|
||||
set[uid] = nil
|
||||
distinct = append(distinct, uid)
|
||||
}
|
||||
|
||||
var (
|
||||
photos = []*Photo{}
|
||||
result = DB.Model(&Photo{}).Where("id IN ?", distinct).Find(&photos)
|
||||
)
|
||||
|
||||
if result.Error == nil {
|
||||
for _, row := range photos {
|
||||
photoMap[row.ID] = row
|
||||
}
|
||||
}
|
||||
|
||||
return photoMap, result.Error
|
||||
}
|
||||
|
||||
// Has a photo ID in the map?
|
||||
func (pm PhotoMap) Has(id uint64) bool {
|
||||
_, ok := pm[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Get a photo from the PhotoMap.
|
||||
func (pm PhotoMap) Get(id uint64) *Photo {
|
||||
if photo, ok := pm[id]; ok {
|
||||
return photo
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
IsSiteGalleryThrottled returns whether the user is throttled from marking additional pictures for the Site Gallery.
|
||||
|
||||
|
@ -451,11 +289,6 @@ func CountPhotosICanSee(user *User, viewer *User) int64 {
|
|||
// MapPhotoCounts returns a mapping of user ID to the CountPhotos()-equivalent result for each.
|
||||
// It's used on the member directory to show photo counts on each user card.
|
||||
func MapPhotoCounts(users []*User) PhotoCountMap {
|
||||
return MapPhotoCountsByVisibility(users, PhotoPublic)
|
||||
}
|
||||
|
||||
// MapPhotoCountsByVisibility returns a mapping of user ID to the CountPhotos()-equivalent result for each.
|
||||
func MapPhotoCountsByVisibility(users []*User, visibility PhotoVisibility) PhotoCountMap {
|
||||
var (
|
||||
userIDs = []uint64{}
|
||||
result = PhotoCountMap{}
|
||||
|
@ -476,7 +309,7 @@ func MapPhotoCountsByVisibility(users []*User, visibility PhotoVisibility) Photo
|
|||
).Select(
|
||||
"user_id, count(id) AS photo_count",
|
||||
).Where(
|
||||
"user_id IN ? AND visibility = ?", userIDs, visibility,
|
||||
"user_id IN ? AND visibility = ?", userIDs, PhotoPublic,
|
||||
).Group("user_id").Scan(&groups); res.Error != nil {
|
||||
log.Error("CountPhotosForUsers: %s", res.Error)
|
||||
}
|
||||
|
@ -595,21 +428,16 @@ func CountExplicitPhotos(userID uint64, visibility []PhotoVisibility) (int64, er
|
|||
|
||||
// CountPublicPhotos returns the number of public photos on a user's page.
|
||||
func CountPublicPhotos(userID uint64) int64 {
|
||||
return CountUserPhotosByVisibility(userID, PhotoPublic)
|
||||
}
|
||||
|
||||
// CountUserPhotosByVisibility returns the number of a user's photos by visibility.
|
||||
func CountUserPhotosByVisibility(userID uint64, visibility PhotoVisibility) int64 {
|
||||
query := DB.Where(
|
||||
"user_id = ? AND visibility = ?",
|
||||
userID,
|
||||
visibility,
|
||||
PhotoPublic,
|
||||
)
|
||||
|
||||
var count int64
|
||||
result := query.Model(&Photo{}).Count(&count)
|
||||
if result.Error != nil {
|
||||
log.Error("CountUserPhotosByVisibility(%d, %s): %s", userID, visibility, result.Error)
|
||||
log.Error("CountPublicPhotos(%d): %s", userID, result.Error)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
@ -644,13 +472,6 @@ func (u *User) DistinctPhotoTypes() (result map[PhotoVisibility]struct{}) {
|
|||
return
|
||||
}
|
||||
|
||||
// FlushCaches clears any cached attributes (such as distinct photo types) for the user.
|
||||
func (u *User) FlushCaches() {
|
||||
u.cachePhotoTypes = nil
|
||||
u.cacheBlockedUserIDs = nil
|
||||
u.cachePhotoIDs = nil
|
||||
}
|
||||
|
||||
// Gallery config for the main Gallery paginator.
|
||||
type Gallery struct {
|
||||
Explicit string // Explicit filter
|
||||
|
@ -682,7 +503,6 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
|
|||
explicitOK = user.Explicit // User opted-in for explicit content
|
||||
|
||||
blocklist = BlockedUserIDs(user)
|
||||
mutelist = MutedUserIDs(user, MutedUserContextSiteGallery)
|
||||
privateUserIDs = PrivateGrantedUserIDs(userID)
|
||||
privateUserIDsAreFriends = PrivateGrantedUserIDsAreFriends(user)
|
||||
wheres = []string{}
|
||||
|
@ -725,9 +545,9 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
|
|||
// Shy users can only see their Friends photos (public or friends visibility)
|
||||
// and any Private photos to whom they were granted access.
|
||||
visOrs = append(visOrs,
|
||||
fmt.Sprintf("(photos.user_id IN %s AND photos.visibility IN ?)", friendsQuery),
|
||||
"(photos.user_id IN ? AND photos.visibility IN ?)",
|
||||
"photos.user_id = ?",
|
||||
fmt.Sprintf("(user_id IN %s AND visibility IN ?)", friendsQuery),
|
||||
"(user_id IN ? AND visibility IN ?)",
|
||||
"user_id = ?",
|
||||
)
|
||||
visPlaceholders = append(visPlaceholders,
|
||||
photosFriends,
|
||||
|
@ -737,23 +557,23 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
|
|||
} else if friendsOnly {
|
||||
// User wants to see only self and friends photos.
|
||||
visOrs = append(visOrs,
|
||||
fmt.Sprintf("(photos.user_id IN %s AND photos.visibility IN ?)", friendsQuery),
|
||||
"photos.user_id = ?",
|
||||
fmt.Sprintf("(user_id IN %s AND visibility IN ?)", friendsQuery),
|
||||
"user_id = ?",
|
||||
)
|
||||
visPlaceholders = append(visPlaceholders, photosFriends, userID)
|
||||
|
||||
// If their friends granted private photos, include those too.
|
||||
if len(privateUserIDsAreFriends) > 0 {
|
||||
visOrs = append(visOrs, "(photos.user_id IN ? AND photos.visibility IN ?)")
|
||||
visOrs = append(visOrs, "(user_id IN ? AND visibility IN ?)")
|
||||
visPlaceholders = append(visPlaceholders, privateUserIDsAreFriends, photosPrivate)
|
||||
}
|
||||
} else {
|
||||
// You can see friends' Friend photos but only public for non-friends.
|
||||
visOrs = append(visOrs,
|
||||
fmt.Sprintf("(photos.user_id IN %s AND photos.visibility IN ?)", friendsQuery),
|
||||
"(photos.user_id IN ? AND photos.visibility IN ?)",
|
||||
fmt.Sprintf("(photos.user_id NOT IN %s AND photos.visibility IN ?)", friendsQuery),
|
||||
"photos.user_id = ?",
|
||||
fmt.Sprintf("(user_id IN %s AND visibility IN ?)", friendsQuery),
|
||||
"(user_id IN ? AND visibility IN ?)",
|
||||
fmt.Sprintf("(user_id NOT IN %s AND visibility IN ?)", friendsQuery),
|
||||
"user_id = ?",
|
||||
)
|
||||
visPlaceholders = append(placeholders,
|
||||
photosFriends,
|
||||
|
@ -768,7 +588,7 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
|
|||
placeholders = append(placeholders, visPlaceholders...)
|
||||
|
||||
// Gallery photos only.
|
||||
wheres = append(wheres, "photos.gallery = ?")
|
||||
wheres = append(wheres, "gallery = ?")
|
||||
placeholders = append(placeholders, true)
|
||||
|
||||
// Filter by photos the user has liked.
|
||||
|
@ -777,9 +597,9 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
|
|||
EXISTS (
|
||||
SELECT 1
|
||||
FROM likes
|
||||
WHERE likes.user_id = ?
|
||||
AND likes.table_name = 'photos'
|
||||
AND likes.table_id = photos.id
|
||||
WHERE user_id = ?
|
||||
AND table_name = 'photos'
|
||||
AND table_id = photos.id
|
||||
)
|
||||
`)
|
||||
placeholders = append(placeholders, user.ID)
|
||||
|
@ -787,45 +607,39 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
|
|||
|
||||
// Filter blocked users.
|
||||
if len(blocklist) > 0 {
|
||||
wheres = append(wheres, "photos.user_id NOT IN ?")
|
||||
wheres = append(wheres, "user_id NOT IN ?")
|
||||
placeholders = append(placeholders, blocklist)
|
||||
}
|
||||
|
||||
// Filter Site Gallery muted users.
|
||||
if len(mutelist) > 0 {
|
||||
wheres = append(wheres, "photos.user_id NOT IN ?")
|
||||
placeholders = append(placeholders, mutelist)
|
||||
}
|
||||
|
||||
// Non-explicit pics unless the user opted in. Allow explicit filter setting to override.
|
||||
if filterExplicit != "" {
|
||||
wheres = append(wheres, "photos.explicit = ?")
|
||||
wheres = append(wheres, "explicit = ?")
|
||||
placeholders = append(placeholders, filterExplicit == "true")
|
||||
} else if !explicitOK {
|
||||
wheres = append(wheres, "photos.explicit = ?")
|
||||
wheres = append(wheres, "explicit = ?")
|
||||
placeholders = append(placeholders, false)
|
||||
}
|
||||
|
||||
// Is the user furthermore clamping the visibility filter?
|
||||
if filterVisibility != "" {
|
||||
wheres = append(wheres, "photos.visibility = ?")
|
||||
wheres = append(wheres, "visibility = ?")
|
||||
placeholders = append(placeholders, filterVisibility)
|
||||
}
|
||||
|
||||
// Only certified (and not banned) user photos.
|
||||
if conf.Uncertified {
|
||||
wheres = append(wheres,
|
||||
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND users.certified IS NOT true AND users.status='active')",
|
||||
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND certified IS NOT true AND status='active')",
|
||||
)
|
||||
} else {
|
||||
wheres = append(wheres,
|
||||
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND users.certified = true AND users.status='active')",
|
||||
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND certified = true AND status='active')",
|
||||
)
|
||||
}
|
||||
|
||||
// Exclude private users' photos.
|
||||
wheres = append(wheres,
|
||||
"NOT EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND photos.visibility = 'private')",
|
||||
"NOT EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND visibility = 'private')",
|
||||
)
|
||||
|
||||
// Admin view: get ALL PHOTOS on the site, period.
|
||||
|
@ -834,14 +648,14 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
|
|||
|
||||
// Admin may filter too.
|
||||
if filterVisibility != "" {
|
||||
query = query.Where("photos.visibility = ?", filterVisibility)
|
||||
query = query.Where("visibility = ?", filterVisibility)
|
||||
}
|
||||
if filterExplicit != "" {
|
||||
query = query.Where("photos.explicit = ?", filterExplicit == "true")
|
||||
query = query.Where("explicit = ?", filterExplicit == "true")
|
||||
}
|
||||
if conf.Uncertified {
|
||||
query = query.Where(
|
||||
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND users.certified IS NOT true AND users.status='active')",
|
||||
"EXISTS (SELECT 1 FROM users WHERE id = photos.user_id AND certified IS NOT true AND status='active')",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
@ -852,32 +666,12 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
|
|||
}
|
||||
|
||||
query = query.Order(pager.Sort)
|
||||
|
||||
query.Model(&Photo{}).Count(&pager.Total)
|
||||
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&p)
|
||||
return p, result.Error
|
||||
}
|
||||
|
||||
// UpdatePhotoCachedCounts will refresh the cached like/comment count on the photos table.
|
||||
func UpdatePhotoCachedCounts(photoID uint64) error {
|
||||
res := DB.Exec(`
|
||||
UPDATE photos
|
||||
SET like_count = (
|
||||
SELECT count(id)
|
||||
FROM likes
|
||||
WHERE table_name='photos'
|
||||
AND table_id=photos.id
|
||||
),
|
||||
comment_count = (
|
||||
SELECT count(id)
|
||||
FROM comments
|
||||
WHERE table_name='photos'
|
||||
AND table_id=photos.id
|
||||
)
|
||||
WHERE photos.id = ?;
|
||||
`, photoID)
|
||||
return res.Error
|
||||
}
|
||||
|
||||
// Save photo.
|
||||
func (p *Photo) Save() error {
|
||||
result := DB.Save(p)
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -126,43 +125,6 @@ func (u *User) AllPhotoIDs() ([]uint64, error) {
|
|||
return photoIDs, nil
|
||||
}
|
||||
|
||||
/*
|
||||
ShouldShowPrivateUnlockPrompt determines whether the current user should be shown a prompt, when viewing
|
||||
the other user's gallery, to unlock their private photos for that user.
|
||||
|
||||
This function verifies that the source user actually has a private photo to share, and that the target
|
||||
user doesn't have a privacy setting enabled that should block the private photo unlock request.
|
||||
*/
|
||||
func ShouldShowPrivateUnlockPrompt(sourceUser, targetUser *User) (bool, error) {
|
||||
|
||||
// If the current user doesn't even have a private photo to share, no prompt.
|
||||
if CountUserPhotosByVisibility(sourceUser.ID, PhotoPrivate) == 0 {
|
||||
return false, errors.New("you do not currently have a private photo on your gallery to share")
|
||||
}
|
||||
|
||||
// Does the target user have a privacy setting enabled?
|
||||
if pp := targetUser.GetProfileField("private_photo_gate"); pp != "" {
|
||||
areFriends := AreFriends(sourceUser.ID, targetUser.ID)
|
||||
|
||||
switch pp {
|
||||
case "nobody":
|
||||
return false, errors.New("they decline all private photo sharing")
|
||||
case "friends":
|
||||
if areFriends {
|
||||
return true, nil
|
||||
}
|
||||
return false, errors.New("they are only accepting private photos from their friends")
|
||||
case "messaged":
|
||||
if areFriends || HasSentAMessage(targetUser, sourceUser) {
|
||||
return true, nil
|
||||
}
|
||||
return false, errors.New("they are only accepting private photos from their friends or from people they have sent a DM to")
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// IsPrivateUnlocked quickly sees if sourceUserID has unlocked private photos for targetUserID to see.
|
||||
func IsPrivateUnlocked(sourceUserID, targetUserID uint64) bool {
|
||||
pb := &PrivatePhoto{}
|
||||
|
|
|
@ -1,105 +0,0 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
/*
|
||||
UsageStatistic holds basic analytics points for things like daily/monthly active user counts.
|
||||
|
||||
Generally, there will be one UserStatistic row for each combination of a UserID and Type for
|
||||
each calendar day of the year. Type names may be like "dau" to log daily logins (Daily Active User),
|
||||
or "chat" to log daily chat room users.
|
||||
|
||||
If a user logs in multiple times in the same day, their existing UsageStatistic for that day
|
||||
is reused and the Counter is incremented. So if a user joins chat 3 times on the same day, there
|
||||
will be a single row for that date for that user, but with a Counter of 3 in that case.
|
||||
|
||||
This makes it easier to query for aggregate reports on daily/monthly active users since each
|
||||
row/event type combo only appears once per user per day.
|
||||
*/
|
||||
type UsageStatistic struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
UserID uint64 `gorm:"uniqueIndex:idx_usage_statistics"`
|
||||
Type string `gorm:"uniqueIndex:idx_usage_statistics"`
|
||||
Date string `gorm:"uniqueIndex:idx_usage_statistics"` // unique days, yyyy-mm-dd format.
|
||||
Counter uint64
|
||||
CreatedAt time.Time `gorm:"index"` // full timestamps
|
||||
UpdatedAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
// Options for UsageStatistic Type values.
|
||||
const (
|
||||
UsageStatisticDailyVisit = "dau" // daily active user counter
|
||||
UsageStatisticChatEntry = "chat" // daily chat room users
|
||||
UsageStatisticForumUser = "forum" // daily forum users (when they open a thread)
|
||||
UsageStatisticGalleryUser = "gallery" // daily Site Gallery user (when viewing the site gallery)
|
||||
)
|
||||
|
||||
// LogDailyActiveUser will ping a UserStatistic for the current user to mark them present for the day.
|
||||
func LogDailyActiveUser(user *User) error {
|
||||
var (
|
||||
date = time.Now().Format(time.DateOnly)
|
||||
_, err = IncrementUsageStatistic(user, UsageStatisticDailyVisit, date)
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// LogDailyChatUser will ping a UserStatistic for the current user to mark them as having used the chat room today.
|
||||
func LogDailyChatUser(user *User) error {
|
||||
var (
|
||||
date = time.Now().Format(time.DateOnly)
|
||||
_, err = IncrementUsageStatistic(user, UsageStatisticChatEntry, date)
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// LogDailyForumUser will ping a UserStatistic for the current user to mark them as having used the forums today.
|
||||
func LogDailyForumUser(user *User) error {
|
||||
var (
|
||||
date = time.Now().Format(time.DateOnly)
|
||||
_, err = IncrementUsageStatistic(user, UsageStatisticForumUser, date)
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// LogDailyGalleryUser will ping a UserStatistic for the current user to mark them as having used the site gallery today.
|
||||
func LogDailyGalleryUser(user *User) error {
|
||||
var (
|
||||
date = time.Now().Format(time.DateOnly)
|
||||
_, err = IncrementUsageStatistic(user, UsageStatisticGalleryUser, date)
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUsageStatistic looks up a user statistic.
|
||||
func GetUsageStatistic(user *User, statType, date string) (*UsageStatistic, error) {
|
||||
var (
|
||||
result = &UsageStatistic{}
|
||||
res = DB.Model(&UsageStatistic{}).Where(
|
||||
"user_id = ? AND type = ? AND date = ?",
|
||||
user.ID, statType, date,
|
||||
).First(&result)
|
||||
)
|
||||
return result, res.Error
|
||||
}
|
||||
|
||||
// IncrementUsageStatistic finds or creates a UserStatistic type and increments the counter.
|
||||
func IncrementUsageStatistic(user *User, statType, date string) (*UsageStatistic, error) {
|
||||
user.muStatistic.Lock()
|
||||
defer user.muStatistic.Unlock()
|
||||
|
||||
// Is there an existing row?
|
||||
stat, err := GetUsageStatistic(user, statType, date)
|
||||
if err != nil {
|
||||
stat = &UsageStatistic{
|
||||
UserID: user.ID,
|
||||
Type: statType,
|
||||
Counter: 0,
|
||||
Date: date,
|
||||
}
|
||||
}
|
||||
|
||||
// Update and save it.
|
||||
stat.Counter++
|
||||
err = DB.Save(stat).Error
|
||||
return stat, err
|
||||
}
|
|
@ -6,7 +6,6 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
|
@ -46,9 +45,6 @@ type User struct {
|
|||
cachePhotoTypes map[PhotoVisibility]struct{}
|
||||
cacheBlockedUserIDs []uint64
|
||||
cachePhotoIDs []uint64
|
||||
|
||||
// Feature mutexes.
|
||||
muStatistic sync.Mutex
|
||||
}
|
||||
|
||||
type UserVisibility string
|
||||
|
@ -230,22 +226,6 @@ func IsValidUsername(username string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// PingLastLoginAt refreshes the user's "last logged in" time.
|
||||
func (u *User) PingLastLoginAt() error {
|
||||
// Also ping their daily active user statistic.
|
||||
if err := LogDailyActiveUser(u); err != nil {
|
||||
log.Error("PingLastLoginAt(%s): couldn't log daily active user statistic: %s", u.Username, err)
|
||||
}
|
||||
|
||||
u.LastLoginAt = time.Now()
|
||||
return u.Save()
|
||||
}
|
||||
|
||||
// IsBanned returns if the user account is banned.
|
||||
func (u *User) IsBanned() bool {
|
||||
return u.Status == UserStatusBanned
|
||||
}
|
||||
|
||||
// IsShyFrom tells whether the user is shy from the perspective of the other user.
|
||||
//
|
||||
// That is, depending on our profile visibility and friendship status.
|
||||
|
@ -291,8 +271,7 @@ func (u *User) CanBeSeenBy(viewer *User) error {
|
|||
|
||||
// UserSearch config.
|
||||
type UserSearch struct {
|
||||
Username string // fuzzy search by name or username
|
||||
InUsername []string // exact set of usernames (e.g. On Chat)
|
||||
Username string
|
||||
Gender string
|
||||
Orientation string
|
||||
MaritalStatus string
|
||||
|
@ -374,11 +353,6 @@ func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, er
|
|||
placeholders = append(placeholders, ilike, ilike)
|
||||
}
|
||||
|
||||
if len(search.InUsername) > 0 {
|
||||
wheres = append(wheres, "users.username IN ?")
|
||||
placeholders = append(placeholders, search.InUsername)
|
||||
}
|
||||
|
||||
if search.Gender != "" {
|
||||
wheres = append(wheres, `
|
||||
EXISTS (
|
||||
|
@ -616,33 +590,6 @@ func MapAdminUsers(user *User) (UserMap, error) {
|
|||
return MapUsers(user, userIDs)
|
||||
}
|
||||
|
||||
// CountBlockedAdminUsers returns a count of how many admin users the current user has blocked, out of how many total.
|
||||
func CountBlockedAdminUsers(user *User) (count, total int64) {
|
||||
// Count the blocked admins.
|
||||
DB.Model(&User{}).Select(
|
||||
"count(users.id) AS cnt",
|
||||
).Joins(
|
||||
"JOIN blocks ON (blocks.target_user_id = users.id)",
|
||||
).Where(
|
||||
"blocks.source_user_id = ? AND users.is_admin IS TRUE",
|
||||
user.ID,
|
||||
).Count(&count)
|
||||
|
||||
// And the total number of available admins.
|
||||
total = CountAdminUsers()
|
||||
return
|
||||
}
|
||||
|
||||
// CountAdminUsers returns a count of how many admin users exist on the site.
|
||||
func CountAdminUsers() (count int64) {
|
||||
DB.Model(&User{}).Select(
|
||||
"count(id) AS cnt",
|
||||
).Where(
|
||||
"users.is_admin IS TRUE",
|
||||
).Count(&count)
|
||||
return
|
||||
}
|
||||
|
||||
// Has a user ID in the map?
|
||||
func (um UserMap) Has(id uint64) bool {
|
||||
_, ok := um[id]
|
||||
|
@ -707,6 +654,28 @@ func (u *User) NameOrUsername() string {
|
|||
}
|
||||
}
|
||||
|
||||
// VisibleAvatarURL returns a URL to the user's avatar taking into account
|
||||
// their relationship with the current user. For example, if the avatar is
|
||||
// friends-only and the current user can't see it, returns the path to the
|
||||
// yellow placeholder avatar instead.
|
||||
//
|
||||
// Expects that UserRelationships are available on the user.
|
||||
func (u *User) VisibleAvatarURL(currentUser *User) string {
|
||||
canSee, visibility := u.CanSeeProfilePicture(currentUser)
|
||||
if canSee {
|
||||
return config.PhotoWebPath + "/" + u.ProfilePhoto.CroppedFilename
|
||||
}
|
||||
|
||||
switch visibility {
|
||||
case PhotoPrivate:
|
||||
return "/static/img/shy-private.png"
|
||||
case PhotoFriends:
|
||||
return "/static/img/shy-friends.png"
|
||||
}
|
||||
|
||||
return "/static/img/shy.png"
|
||||
}
|
||||
|
||||
// CanSeeProfilePicture returns whether the current user can see the user's profile picture.
|
||||
//
|
||||
// Returns a boolean (false if currentUser can't see) and the Visibility setting of the profile photo.
|
||||
|
@ -791,15 +760,6 @@ func (u *User) SetProfileField(name, value string) {
|
|||
}
|
||||
}
|
||||
|
||||
// DeleteProfileField removes a stored profile field.
|
||||
func (u *User) DeleteProfileField(name string) error {
|
||||
res := DB.Exec(
|
||||
"DELETE FROM profile_fields WHERE user_id=? AND name=?",
|
||||
u.ID, name,
|
||||
)
|
||||
return res.Error
|
||||
}
|
||||
|
||||
// GetProfileField returns the value of a profile field or blank string.
|
||||
func (u *User) GetProfileField(name string) string {
|
||||
for _, field := range u.ProfileField {
|
||||
|
|
|
@ -42,8 +42,6 @@ func SetUserRelationships(currentUser *User, users []*User) error {
|
|||
|
||||
// Inject the UserRelationships.
|
||||
for _, u := range users {
|
||||
u.UserRelationship.Computed = true
|
||||
|
||||
if u.ID == currentUser.ID {
|
||||
// Current user - set both bools to true - you can always see your own profile pic.
|
||||
u.UserRelationship.IsFriend = true
|
||||
|
|
|
@ -1,118 +0,0 @@
|
|||
package photo
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/encryption"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/utility"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
// VisibleAvatarURL returns the visible URL image to a user's square profile picture, from the point of view of the currentUser.
|
||||
func VisibleAvatarURL(user, currentUser *models.User) string {
|
||||
canSee, visibility := user.CanSeeProfilePicture(currentUser)
|
||||
if canSee {
|
||||
return SignedPublicAvatarURL(user.ProfilePhoto.CroppedFilename)
|
||||
}
|
||||
|
||||
switch visibility {
|
||||
case models.PhotoPrivate:
|
||||
return "/static/img/shy-private.png"
|
||||
case models.PhotoFriends:
|
||||
return "/static/img/shy-friends.png"
|
||||
}
|
||||
|
||||
return "/static/img/shy.png"
|
||||
}
|
||||
|
||||
// ReSignPhotoLinks will search a blob of text for photo gallery links ("/static/photos/*") and re-sign
|
||||
// their JWT security tokens.
|
||||
func ReSignPhotoLinks(currentUser *models.User, text string) string {
|
||||
var matches = config.PhotoURLRegexp.FindAllStringSubmatch(text, -1)
|
||||
for _, m := range matches {
|
||||
var (
|
||||
origString = m[0]
|
||||
url = m[1]
|
||||
filename string
|
||||
)
|
||||
log.Error("ReSignPhotoLinks: got [%s] url [%s]", origString, url)
|
||||
|
||||
// Trim the /static/photos/ prefix off to get the URL down to its base filename.
|
||||
filename = strings.Split(url, "?")[0]
|
||||
filename = strings.TrimPrefix(filename, config.PhotoWebPath)
|
||||
filename = strings.TrimPrefix(filename, "/")
|
||||
|
||||
// Sign the URL and replace the original.
|
||||
signed := SignedPhotoURL(currentUser, filename)
|
||||
text = strings.ReplaceAll(text, origString, signed)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// SignedPhotoURL returns a URL path to a photo's filename, signed for the current user only.
|
||||
func SignedPhotoURL(user *models.User, filename string) string {
|
||||
return createSignedPhotoURL(user.ID, user.Username, filename, false)
|
||||
}
|
||||
|
||||
// SignedPublicAvatarURL returns a signed URL for a user's public square avatar image, which has
|
||||
// a much more generous JWT expiration lifetime on it.
|
||||
//
|
||||
// The primary use case is for the chat room: users are sent into chat with their avatar URL,
|
||||
// and it must be viewable to all users for a long time.
|
||||
func SignedPublicAvatarURL(filename string) string {
|
||||
return createSignedPhotoURL(0, "@", filename, true)
|
||||
}
|
||||
|
||||
// SignedPhotoClaims are a JWT claims object used to sign and authenticate image (direct .jpg) links.
|
||||
type SignedPhotoClaims struct {
|
||||
FilenameHash string `json:"f"` // Short hash of the Filename being signed.
|
||||
Anyone bool `json:"a,omitempty"` // Non-authenticated signature (e.g. public sq avatar URLs)
|
||||
|
||||
// Standard claims. Notes:
|
||||
// .Subject = username
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// FilenameHash returns a 'short' hash of the filename, for encoding in the SignedPhotoClaims.
|
||||
//
|
||||
// The hash is a truncated SHA256 hash as a basic validation measure against one JWT token being
|
||||
// used to reveal an unrelated picture.
|
||||
func FilenameHash(filename string) string {
|
||||
return encryption.Hash([]byte(filename))[:6]
|
||||
}
|
||||
|
||||
// Common function to create a signed photo URL with an expiration.
|
||||
func createSignedPhotoURL(userID uint64, username string, filename string, anyone bool) string {
|
||||
|
||||
// Claims expire on the 10th of next month.
|
||||
var (
|
||||
expiresAt = utility.NextMonth(time.Now(), 10)
|
||||
claims = SignedPhotoClaims{
|
||||
FilenameHash: FilenameHash(filename),
|
||||
Anyone: anyone,
|
||||
RegisteredClaims: encryption.StandardClaims(userID, username, expiresAt),
|
||||
}
|
||||
)
|
||||
|
||||
// Lock the date stamps for a consistent JWT value for caching.
|
||||
claims.IssuedAt = nil
|
||||
claims.NotBefore = nil
|
||||
|
||||
log.Debug("createSignedPhotoURL(%s): %+v", filename, claims)
|
||||
|
||||
token, err := encryption.SignClaims(claims, []byte(config.Current.SignedPhoto.JWTSecret))
|
||||
if err != nil {
|
||||
log.Error("PhotoURL: SignClaims: %s", err)
|
||||
}
|
||||
|
||||
// JWT query string to append?
|
||||
if token != "" {
|
||||
token = "?jwt=" + token
|
||||
}
|
||||
|
||||
return URLPath(filename) + token
|
||||
}
|
|
@ -17,7 +17,6 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/controller/htmx"
|
||||
"code.nonshy.com/nonshy/website/pkg/controller/inbox"
|
||||
"code.nonshy.com/nonshy/website/pkg/controller/index"
|
||||
"code.nonshy.com/nonshy/website/pkg/controller/mutelist"
|
||||
"code.nonshy.com/nonshy/website/pkg/controller/photo"
|
||||
"code.nonshy.com/nonshy/website/pkg/controller/poll"
|
||||
"code.nonshy.com/nonshy/website/pkg/middleware"
|
||||
|
@ -35,10 +34,8 @@ func New() http.Handler {
|
|||
mux.HandleFunc("GET /sw.js", index.ServiceWorker())
|
||||
mux.HandleFunc("GET /about", index.StaticTemplate("about.html")())
|
||||
mux.HandleFunc("GET /features", index.StaticTemplate("features.html")())
|
||||
mux.HandleFunc("GET /insights", index.Demographics())
|
||||
mux.HandleFunc("GET /faq", index.StaticTemplate("faq.html")())
|
||||
mux.HandleFunc("GET /tos", index.StaticTemplate("tos.html")())
|
||||
mux.HandleFunc("GET /tos/photos", index.StaticTemplate("tos_photos.html")())
|
||||
mux.HandleFunc("GET /privacy", index.StaticTemplate("privacy.html")())
|
||||
mux.HandleFunc("/contact", index.Contact())
|
||||
mux.HandleFunc("/login", account.Login())
|
||||
|
@ -65,7 +62,6 @@ func New() http.Handler {
|
|||
mux.Handle("GET /photo/view", middleware.LoginRequired(photo.View()))
|
||||
mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit()))
|
||||
mux.Handle("/photo/delete", middleware.LoginRequired(photo.Delete()))
|
||||
mux.Handle("/photo/batch-edit", middleware.LoginRequired(photo.BatchEdit()))
|
||||
mux.Handle("/photo/certification", middleware.LoginRequired(photo.Certification()))
|
||||
mux.Handle("GET /photo/private", middleware.LoginRequired(photo.Private()))
|
||||
mux.Handle("/photo/private/share", middleware.LoginRequired(photo.Share()))
|
||||
|
@ -79,9 +75,6 @@ func New() http.Handler {
|
|||
mux.Handle("POST /users/block", middleware.LoginRequired(block.BlockUser()))
|
||||
mux.Handle("GET /users/blocked", middleware.LoginRequired(block.Blocked()))
|
||||
mux.Handle("GET /users/blocklist/add", middleware.LoginRequired(block.AddUser()))
|
||||
mux.Handle("GET /users/muted", middleware.LoginRequired(mutelist.MuteList()))
|
||||
mux.Handle("GET /users/mutelist/add", middleware.LoginRequired(mutelist.AddUser()))
|
||||
mux.Handle("POST /users/mutelist/add", middleware.LoginRequired(mutelist.MuteUser()))
|
||||
mux.Handle("/comments", middleware.LoginRequired(comment.PostComment()))
|
||||
mux.Handle("GET /comments/subscription", middleware.LoginRequired(comment.Subscription()))
|
||||
mux.Handle("GET /admin/unimpersonate", middleware.LoginRequired(admin.Unimpersonate()))
|
||||
|
@ -118,7 +111,6 @@ func New() http.Handler {
|
|||
|
||||
// JSON API endpoints.
|
||||
mux.HandleFunc("GET /v1/version", api.Version())
|
||||
mux.HandleFunc("GET /v1/auth/static", api.PhotoSignAuth())
|
||||
mux.HandleFunc("GET /v1/users/me", api.LoginOK())
|
||||
mux.HandleFunc("POST /v1/users/check-username", api.UsernameCheck())
|
||||
mux.HandleFunc("GET /v1/web-push/vapid-public-key", webpush.VAPIDPublicKey)
|
||||
|
@ -126,7 +118,6 @@ func New() http.Handler {
|
|||
mux.Handle("GET /v1/web-push/unregister", middleware.LoginRequired(webpush.UnregisterAll()))
|
||||
mux.Handle("POST /v1/likes", middleware.LoginRequired(api.Likes()))
|
||||
mux.Handle("GET /v1/likes/users", middleware.LoginRequired(api.WhoLikes()))
|
||||
mux.Handle("POST /v1/photo/{photo_id}/view", middleware.LoginRequired(api.ViewPhoto()))
|
||||
mux.Handle("POST /v1/notifications/read", middleware.LoginRequired(api.ReadNotification()))
|
||||
mux.Handle("POST /v1/notifications/delete", middleware.LoginRequired(api.ClearNotification()))
|
||||
mux.Handle("POST /v1/photos/mark-explicit", middleware.LoginRequired(api.MarkPhotoExplicit()))
|
||||
|
|
|
@ -172,7 +172,8 @@ func LoginUser(w http.ResponseWriter, r *http.Request, u *models.User) error {
|
|||
sess.Save(w)
|
||||
|
||||
// Ping the user's last login time.
|
||||
return u.PingLastLoginAt()
|
||||
u.LastLoginAt = time.Now()
|
||||
return u.Save()
|
||||
}
|
||||
|
||||
// ImpersonateUser assumes the role of the user impersonated by an admin uid.
|
||||
|
|
|
@ -35,18 +35,12 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
|||
"FormatNumberCommas": FormatNumberCommas(),
|
||||
"ComputeAge": utility.Age,
|
||||
"Split": strings.Split,
|
||||
"NewHashMap": NewHashMap,
|
||||
"ToMarkdown": ToMarkdown,
|
||||
"DeMarkify": markdown.DeMarkify,
|
||||
"ToJSON": ToJSON,
|
||||
"ToHTML": ToHTML,
|
||||
"ToString": func(v interface{}) string {
|
||||
return fmt.Sprintf("%v", v)
|
||||
},
|
||||
"PhotoURL": PhotoURL(r),
|
||||
"VisibleAvatarURL": photo.VisibleAvatarURL,
|
||||
"Now": time.Now,
|
||||
"RunTime": RunTime,
|
||||
"PhotoURL": photo.URLPath,
|
||||
"Now": time.Now,
|
||||
"RunTime": RunTime,
|
||||
"PrettyTitle": func() template.HTML {
|
||||
return template.HTML(fmt.Sprintf(
|
||||
`<strong style="color: #0077FF">non</strong>` +
|
||||
|
@ -76,14 +70,6 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
|||
|
||||
// Get a description for an admin scope (e.g. for transparency page).
|
||||
"AdminScopeDescription": config.AdminScopeDescription,
|
||||
|
||||
// "ReSignPhotoLinks": photo.ReSignPhotoLinks,
|
||||
"ReSignPhotoLinks": func(s template.HTML) template.HTML {
|
||||
if currentUser, err := session.CurrentUser(r); err == nil {
|
||||
return template.HTML(photo.ReSignPhotoLinks(currentUser, string(s)))
|
||||
}
|
||||
return s
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -112,19 +98,6 @@ func RunTime(r *http.Request) string {
|
|||
return "ERROR"
|
||||
}
|
||||
|
||||
// PhotoURL returns a URL path to photos.
|
||||
func PhotoURL(r *http.Request) func(filename string) string {
|
||||
return func(filename string) string {
|
||||
// Get the current user to sign a JWT token.
|
||||
var token string
|
||||
if currentUser, err := session.CurrentUser(r); err == nil {
|
||||
return photo.SignedPhotoURL(currentUser, filename)
|
||||
}
|
||||
|
||||
return photo.URLPath(filename) + token
|
||||
}
|
||||
}
|
||||
|
||||
// BlurExplicit returns true if the current user has the blur_explicit setting on and the given Photo is Explicit.
|
||||
func BlurExplicit(r *http.Request) func(*models.Photo) bool {
|
||||
return func(photo *models.Photo) bool {
|
||||
|
@ -265,30 +238,6 @@ func SubtractInt64(a, b int64) int64 {
|
|||
return a - b
|
||||
}
|
||||
|
||||
// NewHashMap creates a key/value dict on the fly for Go templates.
|
||||
//
|
||||
// Use it like: {{$Vars := NewHashMap "username" .CurrentUser.Username "photoID" .Photo.ID}}
|
||||
//
|
||||
// It is useful for calling Go subtemplates that need custom parameters, e.g. a
|
||||
// mixin from current scope with other variables.
|
||||
func NewHashMap(upsert ...interface{}) map[string]interface{} {
|
||||
// Map the positional arguments into a dictionary.
|
||||
var params = map[string]interface{}{}
|
||||
for i := 0; i < len(upsert); i += 2 {
|
||||
var (
|
||||
key = fmt.Sprintf("%v", upsert[i])
|
||||
value interface{}
|
||||
)
|
||||
if len(upsert) > i {
|
||||
value = upsert[i+1]
|
||||
}
|
||||
|
||||
params[key] = value
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// UrlEncode escapes a series of values (joined with no delimiter)
|
||||
func UrlEncode(values ...interface{}) string {
|
||||
var result string
|
||||
|
|
|
@ -162,7 +162,6 @@ func (t *Template) Reload() error {
|
|||
// Base template layout.
|
||||
var baseTemplates = []string{
|
||||
config.TemplatePath + "/base.html",
|
||||
config.TemplatePath + "/partials/alert_modal.html",
|
||||
config.TemplatePath + "/partials/user_avatar.html",
|
||||
config.TemplatePath + "/partials/like_modal.html",
|
||||
config.TemplatePath + "/partials/right_click.html",
|
||||
|
|
|
@ -1,29 +0,0 @@
|
|||
package utility
|
||||
|
||||
import "code.nonshy.com/nonshy/website/pkg/config"
|
||||
|
||||
// StringInOptions constrains a string value (posted by the user) to only be one
|
||||
// of the available values in the Option list enum. Returns the string if OK, or
|
||||
// else the default string.
|
||||
func StringInOptions(v string, options []config.Option, orDefault string) string {
|
||||
for _, option := range options {
|
||||
if v == option.Value {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return orDefault
|
||||
}
|
||||
|
||||
// StringInOptGroup constrains a string value (posted by the user) to only be one
|
||||
// of the available values in the Option list enum. Returns the string if OK, or
|
||||
// else the default string.
|
||||
func StringInOptGroup(v string, options []config.OptGroup, orDefault string) string {
|
||||
for _, group := range options {
|
||||
for _, option := range group.Options {
|
||||
if v == option.Value {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return orDefault
|
||||
}
|
|
@ -6,17 +6,6 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// FormatFloatToPrecision will trim a floating point number to at most a number of decimals of precision.
|
||||
//
|
||||
// If the precision is ".0" the decimal place will be stripped entirely.
|
||||
func FormatFloatToPrecision(v float64, prec int) string {
|
||||
s := strconv.FormatFloat(v, 'f', prec, 64)
|
||||
if strings.HasSuffix(s, ".0") {
|
||||
return strings.Split(s, ".")[0]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// FormatNumberShort compresses a number into as short a string as possible (e.g. "1.2K" when it gets into the thousands).
|
||||
func FormatNumberShort(value int64) string {
|
||||
// Under 1,000?
|
||||
|
@ -31,13 +20,21 @@ func FormatNumberShort(value int64) string {
|
|||
billions = float64(millions) / 1000
|
||||
)
|
||||
|
||||
formatFloat := func(v float64) string {
|
||||
s := strconv.FormatFloat(v, 'f', 1, 64)
|
||||
if strings.HasSuffix(s, ".0") {
|
||||
return strings.Split(s, ".")[0]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
if thousands < 1000 {
|
||||
return fmt.Sprintf("%sK", FormatFloatToPrecision(thousands, 1))
|
||||
return fmt.Sprintf("%sK", formatFloat(thousands))
|
||||
}
|
||||
|
||||
if millions < 1000 {
|
||||
return fmt.Sprintf("%sM", FormatFloatToPrecision(millions, 1))
|
||||
return fmt.Sprintf("%sM", formatFloat(millions))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%sB", FormatFloatToPrecision(billions, 1))
|
||||
return fmt.Sprintf("%sB", formatFloat(billions))
|
||||
}
|
||||
|
|
|
@ -7,22 +7,6 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
// NextMonth takes an input time (usually time.Now) and will return the next month on the given day.
|
||||
//
|
||||
// Example, NextMonth from any time in April should return e.g. May 10th.
|
||||
func NextMonth(now time.Time, day int) time.Time {
|
||||
var (
|
||||
year, month, _ = now.Date()
|
||||
nextMonth = month + 1
|
||||
)
|
||||
if nextMonth > 12 {
|
||||
nextMonth = 1
|
||||
year++
|
||||
}
|
||||
|
||||
return time.Date(year, nextMonth, day, 0, 0, 0, 0, now.Location())
|
||||
}
|
||||
|
||||
// FormatDurationCoarse returns a pretty printed duration with coarse granularity.
|
||||
func FormatDurationCoarse(duration time.Duration) string {
|
||||
// Negative durations (e.g. future dates) should work too.
|
||||
|
|
|
@ -7,53 +7,6 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/utility"
|
||||
)
|
||||
|
||||
func TestNextMonth(t *testing.T) {
|
||||
var tests = []struct {
|
||||
Now string
|
||||
Day int
|
||||
Expect string
|
||||
}{
|
||||
{
|
||||
Now: "1995-08-01",
|
||||
Day: 15,
|
||||
Expect: "1995-09-15",
|
||||
},
|
||||
{
|
||||
Now: "2006-12-15",
|
||||
Day: 11,
|
||||
Expect: "2007-01-11",
|
||||
},
|
||||
{
|
||||
Now: "2006-12-01",
|
||||
Day: 15,
|
||||
Expect: "2007-01-15",
|
||||
},
|
||||
{
|
||||
Now: "2007-01-15",
|
||||
Day: 29, // no leap day
|
||||
Expect: "2007-03-01",
|
||||
},
|
||||
{
|
||||
Now: "2004-01-08",
|
||||
Day: 29, // leap day
|
||||
Expect: "2004-02-29",
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
now, err := time.Parse(time.DateOnly, test.Now)
|
||||
if err != nil {
|
||||
t.Errorf("Test #%d: parse error: %s", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
actual := utility.NextMonth(now, test.Day).Format("2006-01-02")
|
||||
if actual != test.Expect {
|
||||
t.Errorf("Test #%d: expected %s but got %s", i, test.Expect, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDurationCoarse(t *testing.T) {
|
||||
var tests = []struct {
|
||||
In time.Duration
|
||||
|
|
|
@ -30,14 +30,6 @@
|
|||
color: rgb(26, 0, 5) !important;
|
||||
}
|
||||
|
||||
/* hero.is-light.is-bold still is white on Bulma's dark theme */
|
||||
.hero.is-light.is-bold {
|
||||
background-image: linear-gradient(141deg, #333 0, #181818 100%);
|
||||
* {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* force lit-up notification buttons (on the mobile top nav, e.g. new Messages/Friends)
|
||||
to show as a bright bulma is-warning style (.tag.is-warning) */
|
||||
.nonshy-navbar-notification {
|
||||
|
@ -45,11 +37,6 @@
|
|||
color: rgb(26, 0, 5) !important;
|
||||
}
|
||||
|
||||
/* glassy background for fixed nav bar when you scroll other elements under it */
|
||||
nav.navbar {
|
||||
background-color: rgba(20, 22, 26, .75) !important;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.has-text-dark {
|
||||
/* note: this css file otherwise didn't override this, dark's always dark, brighten it! */
|
||||
|
@ -78,19 +65,3 @@ a.has-text-dark:hover {
|
|||
background-color: #550;
|
||||
color: #FFC;
|
||||
}
|
||||
|
||||
/*
|
||||
* Forum Color overrides for dark theme.
|
||||
*/
|
||||
.nonshy-forum-box-1 {
|
||||
/* Outermost box: Forum or Thread top-level wrappers */
|
||||
background-color: #163b27;
|
||||
}
|
||||
.nonshy-forum-box-2 {
|
||||
/* Nested box: "Latest Post" on Forum-level views */
|
||||
background-color: #144225;
|
||||
}
|
||||
.nonshy-forum-box-3 {
|
||||
/* Nested box: Topics/Posts/Users/View counters */
|
||||
background-color: #2c2812;
|
||||
}
|
|
@ -1,3 +1,3 @@
|
|||
/* Custom nonshy color overrides for Bulma's dark theme
|
||||
(prefers-dark edition) */
|
||||
@import url("dark-theme.css?nocache=2") screen and (prefers-color-scheme: dark);
|
||||
@import url("dark-theme.css") screen and (prefers-color-scheme: dark);
|
|
@ -1,15 +0,0 @@
|
|||
/*
|
||||
* Forum Color overrides for dark theme.
|
||||
*/
|
||||
.nonshy-forum-box-1 {
|
||||
/* Outermost box: Forum or Thread top-level wrappers */
|
||||
background-color: #18163b;
|
||||
}
|
||||
.nonshy-forum-box-2 {
|
||||
/* Nested box: "Latest Post" on Forum-level views */
|
||||
background-color: #142842;
|
||||
}
|
||||
.nonshy-forum-box-3 {
|
||||
/* Nested box: Topics/Posts/Users/View counters */
|
||||
background-color: #17122c;
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
/*
|
||||
* Forum Color overrides for dark theme.
|
||||
*/
|
||||
.nonshy-forum-box-1 {
|
||||
/* Outermost box: Forum or Thread top-level wrappers */
|
||||
background-color: #3b1638;
|
||||
}
|
||||
.nonshy-forum-box-2 {
|
||||
/* Nested box: "Latest Post" on Forum-level views */
|
||||
background-color: #42143e;
|
||||
}
|
||||
.nonshy-forum-box-3 {
|
||||
/* Nested box: Topics/Posts/Users/View counters */
|
||||
background-color: #1e122c;
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
:root {
|
||||
--bulma-primary-h: 300deg;
|
||||
--bulma-primary-l: 80%;
|
||||
--bulma-link-h: 204deg;
|
||||
--bulma-link-l: 50%;
|
||||
--bulma-scheme-h: 299;
|
||||
--bulma-scheme-s: 22%;
|
||||
}
|
||||
|
||||
/*
|
||||
* Forum Colors: with all the nested boxes, Bulma's default color scheme is
|
||||
* too limited for a good experience.
|
||||
*/
|
||||
.nonshy-forum-box-1 {
|
||||
/* Outermost box: Forum or Thread top-level wrappers */
|
||||
background-color: #fbd1ff;
|
||||
}
|
||||
.nonshy-forum-box-2 {
|
||||
/* Nested box: "Latest Post" on Forum-level views */
|
||||
background-color: #ddd0f1;
|
||||
}
|
||||
.nonshy-forum-box-3 {
|
||||
/* Nested box: Topics/Posts/Users/View counters */
|
||||
background-color: #e7c2ff;
|
||||
}
|
||||
|
||||
/* Chat members in top nav bar on desktop */
|
||||
.nonshy-navbar-notification-tag.is-link, .nonshy-mobile-notification .tag.is-link {
|
||||
background-color: rgb(255, 104, 247);
|
||||
color: #606;
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
:root {
|
||||
--bulma-primary-h: 204deg;
|
||||
--bulma-primary-l: 60%;
|
||||
--bulma-link-h: 200deg;
|
||||
--bulma-link-l: 34%;
|
||||
--bulma-scheme-h: 173;
|
||||
}
|
||||
|
||||
/*
|
||||
* Forum Colors: with all the nested boxes, Bulma's default color scheme is
|
||||
* too limited for a good experience.
|
||||
*/
|
||||
.nonshy-forum-box-1 {
|
||||
/* Outermost box: Forum or Thread top-level wrappers */
|
||||
background-color: #d1f0ff;
|
||||
}
|
||||
.nonshy-forum-box-2 {
|
||||
/* Nested box: "Latest Post" on Forum-level views */
|
||||
background-color: #d6d9ff;
|
||||
}
|
||||
.nonshy-forum-box-3 {
|
||||
/* Nested box: Topics/Posts/Users/View counters */
|
||||
background-color: #c2d2ff;
|
||||
}
|
||||
|
||||
/* Chat members in top nav bar on desktop */
|
||||
.nonshy-navbar-notification-tag.is-link {
|
||||
background-color: rgb(66, 142, 255);
|
||||
color: #fff;
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
/*
|
||||
* Forum Color overrides for dark theme.
|
||||
*/
|
||||
.nonshy-forum-box-1 {
|
||||
/* Outermost box: Forum or Thread top-level wrappers */
|
||||
background-color: #193b16;
|
||||
}
|
||||
.nonshy-forum-box-2 {
|
||||
/* Nested box: "Latest Post" on Forum-level views */
|
||||
background-color: #2e4214;
|
||||
}
|
||||
.nonshy-forum-box-3 {
|
||||
/* Nested box: Topics/Posts/Users/View counters */
|
||||
background-color: #122c1f;
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
:root {
|
||||
--bulma-primary-h: 99deg;
|
||||
--bulma-link-h: 112deg;
|
||||
--bulma-link-l: 18%;
|
||||
--bulma-scheme-h: 95;
|
||||
--bulma-link-text: #009900;
|
||||
}
|
||||
|
||||
/*
|
||||
* Forum Colors: with all the nested boxes, Bulma's default color scheme is
|
||||
* too limited for a good experience.
|
||||
*/
|
||||
.nonshy-forum-box-1 {
|
||||
/* Outermost box: Forum or Thread top-level wrappers */
|
||||
background-color: #d1ffd1;
|
||||
}
|
||||
.nonshy-forum-box-2 {
|
||||
/* Nested box: "Latest Post" on Forum-level views */
|
||||
background-color: #e3f1d0;
|
||||
}
|
||||
.nonshy-forum-box-3 {
|
||||
/* Nested box: Topics/Posts/Users/View counters */
|
||||
background-color: #c2ffe8;
|
||||
}
|
||||
|
||||
/* Chat members in top nav bar on desktop */
|
||||
.nonshy-navbar-notification-tag.is-link {
|
||||
background-color: rgb(28, 133, 33);
|
||||
color: #fff;
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
/*
|
||||
* Forum Color overrides for dark theme.
|
||||
*/
|
||||
.nonshy-forum-box-1 {
|
||||
/* Outermost box: Forum or Thread top-level wrappers */
|
||||
background-color: #3b2816;
|
||||
}
|
||||
.nonshy-forum-box-2 {
|
||||
/* Nested box: "Latest Post" on Forum-level views */
|
||||
background-color: #422a14;
|
||||
}
|
||||
.nonshy-forum-box-3 {
|
||||
/* Nested box: Topics/Posts/Users/View counters */
|
||||
background-color: #2c1912;
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
:root {
|
||||
--bulma-primary-h: 39deg;
|
||||
--bulma-link-h: 21deg;
|
||||
--bulma-link-l: 39%;
|
||||
--bulma-scheme-h: 34;
|
||||
}
|
||||
|
||||
/*
|
||||
* Forum Colors: with all the nested boxes, Bulma's default color scheme is
|
||||
* too limited for a good experience.
|
||||
*/
|
||||
.nonshy-forum-box-1 {
|
||||
/* Outermost box: Forum or Thread top-level wrappers */
|
||||
background-color: #ffe2d1;
|
||||
}
|
||||
.nonshy-forum-box-2 {
|
||||
/* Nested box: "Latest Post" on Forum-level views */
|
||||
background-color: #f1e8d0;
|
||||
}
|
||||
.nonshy-forum-box-3 {
|
||||
/* Nested box: Topics/Posts/Users/View counters */
|
||||
background-color: #ffcbc2;
|
||||
}
|
||||
|
||||
/* Chat members in top nav bar on desktop */
|
||||
.nonshy-navbar-notification-tag.is-link {
|
||||
background-color: rgb(255, 110, 66);
|
||||
color: #fff;
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
/*
|
||||
* Forum Color overrides for dark theme.
|
||||
*/
|
||||
.nonshy-forum-box-1 {
|
||||
/* Outermost box: Forum or Thread top-level wrappers */
|
||||
background-color: #3b1638;
|
||||
}
|
||||
.nonshy-forum-box-2 {
|
||||
/* Nested box: "Latest Post" on Forum-level views */
|
||||
background-color: #42143e;
|
||||
}
|
||||
.nonshy-forum-box-3 {
|
||||
/* Nested box: Topics/Posts/Users/View counters */
|
||||
background-color: #1e122c;
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
:root {
|
||||
--bulma-primary-h: 300deg;
|
||||
--bulma-primary-l: 80%;
|
||||
--bulma-link-h: 293deg;
|
||||
--bulma-scheme-h: 295;
|
||||
--bulma-scheme-s: 39%;
|
||||
}
|
||||
|
||||
/*
|
||||
* Forum Colors: with all the nested boxes, Bulma's default color scheme is
|
||||
* too limited for a good experience.
|
||||
*/
|
||||
.nonshy-forum-box-1 {
|
||||
/* Outermost box: Forum or Thread top-level wrappers */
|
||||
background-color: #fbd1ff;
|
||||
}
|
||||
.nonshy-forum-box-2 {
|
||||
/* Nested box: "Latest Post" on Forum-level views */
|
||||
background-color: #ddd0f1;
|
||||
}
|
||||
.nonshy-forum-box-3 {
|
||||
/* Nested box: Topics/Posts/Users/View counters */
|
||||
background-color: #e7c2ff;
|
||||
}
|
||||
|
||||
/* Chat members in top nav bar on desktop */
|
||||
.nonshy-navbar-notification-tag.is-link {
|
||||
background-color: rgb(255, 104, 247);
|
||||
color: #606;
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
/*
|
||||
* Forum Color overrides for dark theme.
|
||||
*/
|
||||
.nonshy-forum-box-1 {
|
||||
/* Outermost box: Forum or Thread top-level wrappers */
|
||||
background-color: #37163b;
|
||||
}
|
||||
.nonshy-forum-box-2 {
|
||||
/* Nested box: "Latest Post" on Forum-level views */
|
||||
background-color: #3e1442;
|
||||
}
|
||||
.nonshy-forum-box-3 {
|
||||
/* Nested box: Topics/Posts/Users/View counters */
|
||||
background-color: #1e122c;
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
:root {
|
||||
--bulma-primary-h: 292deg;
|
||||
--bulma-primary-l: 60%;
|
||||
--bulma-link-h: 277deg;
|
||||
--bulma-link-l: 45%;
|
||||
--bulma-scheme-h: 293;
|
||||
--bulma-scheme-s: 23%;
|
||||
}
|
||||
|
||||
/*
|
||||
* Forum Colors: with all the nested boxes, Bulma's default color scheme is
|
||||
* too limited for a good experience.
|
||||
*/
|
||||
.nonshy-forum-box-1 {
|
||||
/* Outermost box: Forum or Thread top-level wrappers */
|
||||
background-color: #f4d1ff;
|
||||
}
|
||||
.nonshy-forum-box-2 {
|
||||
/* Nested box: "Latest Post" on Forum-level views */
|
||||
background-color: #f1d0ef;
|
||||
}
|
||||
.nonshy-forum-box-3 {
|
||||
/* Nested box: Topics/Posts/Users/View counters */
|
||||
background-color: #d7c2ff;
|
||||
}
|
||||
|
||||
/* Chat members in top nav bar on desktop */
|
||||
.nonshy-navbar-notification-tag.is-link {
|
||||
background-color: rgb(170, 66, 255);
|
||||
color: #fff;
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
/*
|
||||
* Forum Color overrides for dark theme.
|
||||
*/
|
||||
.nonshy-forum-box-1 {
|
||||
/* Outermost box: Forum or Thread top-level wrappers */
|
||||
background-color: #3b1616;
|
||||
}
|
||||
.nonshy-forum-box-2 {
|
||||
/* Nested box: "Latest Post" on Forum-level views */
|
||||
background-color: #421d14;
|
||||
}
|
||||
.nonshy-forum-box-3 {
|
||||
/* Nested box: Topics/Posts/Users/View counters */
|
||||
background-color: #2c1912;
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
:root {
|
||||
--bulma-primary-h: 15deg;
|
||||
--bulma-primary-l: 63%;
|
||||
--bulma-link-h: 12deg;
|
||||
--bulma-link-l: 30%;
|
||||
--bulma-scheme-h: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Forum Colors: with all the nested boxes, Bulma's default color scheme is
|
||||
* too limited for a good experience.
|
||||
*/
|
||||
.nonshy-forum-box-1 {
|
||||
/* Outermost box: Forum or Thread top-level wrappers */
|
||||
background-color: #ffd1d1;
|
||||
}
|
||||
.nonshy-forum-box-2 {
|
||||
/* Nested box: "Latest Post" on Forum-level views */
|
||||
background-color: #f1d8d0;
|
||||
}
|
||||
.nonshy-forum-box-3 {
|
||||
/* Nested box: Topics/Posts/Users/View counters */
|
||||
background-color: #ffd0c2;
|
||||
}
|
||||
|
||||
/* Chat members in top nav bar on desktop */
|
||||
.nonshy-navbar-notification-tag.is-link {
|
||||
background-color: rgb(255, 66, 66);
|
||||
color: #fff;
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
/*
|
||||
* Forum Color overrides for dark theme.
|
||||
*/
|
||||
.nonshy-forum-box-1 {
|
||||
/* Outermost box: Forum or Thread top-level wrappers */
|
||||
background-color: #3b3516;
|
||||
}
|
||||
.nonshy-forum-box-2 {
|
||||
/* Nested box: "Latest Post" on Forum-level views */
|
||||
background-color: #423714;
|
||||
}
|
||||
.nonshy-forum-box-3 {
|
||||
/* Nested box: Topics/Posts/Users/View counters */
|
||||
background-color: #2c2c12;
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
:root {
|
||||
--bulma-primary-h: 59deg;
|
||||
--bulma-primary-l: 51%;
|
||||
--bulma-link-h: 57deg;
|
||||
--bulma-link-l: 26%;
|
||||
--bulma-scheme-h: 62;
|
||||
--bulma-link-text: #999900;
|
||||
}
|
||||
|
||||
/*
|
||||
* Forum Colors: with all the nested boxes, Bulma's default color scheme is
|
||||
* too limited for a good experience.
|
||||
*/
|
||||
.nonshy-forum-box-1 {
|
||||
/* Outermost box: Forum or Thread top-level wrappers */
|
||||
background-color: #ffe2d1;
|
||||
}
|
||||
.nonshy-forum-box-2 {
|
||||
/* Nested box: "Latest Post" on Forum-level views */
|
||||
background-color: #f1e8d0;
|
||||
}
|
||||
.nonshy-forum-box-3 {
|
||||
/* Nested box: Topics/Posts/Users/View counters */
|
||||
background-color: #ffcbc2;
|
||||
}
|
||||
|
||||
/* Chat members in top nav bar on desktop */
|
||||
.nonshy-navbar-notification-tag.is-link {
|
||||
background-color: rgb(163, 145, 40);
|
||||
color: #fff;
|
||||
}
|
|
@ -1,11 +1,5 @@
|
|||
/* Custom CSS styles */
|
||||
|
||||
html {
|
||||
/* With the fixed top nav bar, this works around anchor-links to make them appear
|
||||
correctly BELOW the nav bar instead of overlapped underneath it. */
|
||||
scroll-padding-top: 52px;
|
||||
}
|
||||
|
||||
abbr {
|
||||
cursor: help;
|
||||
}
|
||||
|
@ -22,10 +16,6 @@ abbr {
|
|||
cursor: default;
|
||||
}
|
||||
|
||||
.has-text-smaller {
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
img {
|
||||
/* https://stackoverflow.com/questions/12906789/preventing-an-image-from-being-draggable-or-selectable-without-using-js */
|
||||
user-drag: none;
|
||||
|
@ -106,18 +96,12 @@ img {
|
|||
|
||||
/* Mobile: notification badge near the hamburger menu */
|
||||
.nonshy-mobile-notification {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 50px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* glassy background for fixed nav bar when you scroll other elements under it */
|
||||
nav.navbar {
|
||||
background-color: rgba(255, 255, 255, .75);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
/* PWA: loading indicator in the corner of the page */
|
||||
#nonshy-pwa-loader {
|
||||
display: none;
|
||||
|
@ -165,12 +149,6 @@ nav.navbar {
|
|||
height: 1.5em !important;
|
||||
}
|
||||
|
||||
/* Bulma breadcrumbs don't word-wrap which can cause horizontal scrolling on mobile,
|
||||
especially on forum thread pages with a long thread title. */
|
||||
.breadcrumb {
|
||||
white-space: inherit;
|
||||
}
|
||||
|
||||
/***
|
||||
* Mobile navbar notification count badge no.
|
||||
*/
|
||||
|
@ -250,21 +228,4 @@ nav.navbar {
|
|||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Forum Colors: with all the nested boxes, Bulma's default color scheme is
|
||||
* too limited for a good experience.
|
||||
*/
|
||||
.nonshy-forum-box-1 {
|
||||
/* Outermost box: Forum or Thread top-level wrappers */
|
||||
background-color: #d1fff8;
|
||||
}
|
||||
.nonshy-forum-box-2 {
|
||||
/* Nested box: "Latest Post" on Forum-level views */
|
||||
background-color: #d0f1e2;
|
||||
}
|
||||
.nonshy-forum-box-3 {
|
||||
/* Nested box: Topics/Posts/Users/View counters */
|
||||
background-color: #ffedc2;
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 93 KiB |
|
@ -1,137 +0,0 @@
|
|||
/**
|
||||
* Alert and Confirm modals.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* modalAlert({message: "Hello world!"}).then(callback);
|
||||
* modalConfirm({message: "Are you sure?"}).then(callback);
|
||||
*
|
||||
* Available options for modalAlert:
|
||||
* - message
|
||||
* - title: Alert
|
||||
*
|
||||
* Available options for modalConfirm:
|
||||
* - message
|
||||
* - title: Confirm
|
||||
* - buttons: ["Ok", "Cancel"]
|
||||
* - event (pass `event` for easy inline onclick handlers)
|
||||
* - element (pass `this` for easy inline onclick handlers)
|
||||
*
|
||||
* Example onclick for modalConfirm:
|
||||
*
|
||||
* <button onclick="modalConfirm({message: 'Are you sure?', event, element}">Delete</button>
|
||||
*
|
||||
* The `element` is used to find the nearest <form> and submit it on OK.
|
||||
* The `event` is used to cancel the submit button's default.
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const $modal = document.querySelector("#nonshy-alert-modal"),
|
||||
$ok = $modal.querySelector("button.nonshy-alert-ok-button"),
|
||||
$cancel = $modal.querySelector("button.nonshy-alert-cancel-button"),
|
||||
$title = $modal.querySelector("#nonshy-alert-modal-title"),
|
||||
$body = $modal.querySelector("#nonshy-alert-modal-body"),
|
||||
alertIcon = `<i class="fa fa-exclamation-triangle mr-2"></i>`,
|
||||
confirmIcon = `<i class="fa fa-question-circle mr-2"></i>`,
|
||||
cls = 'is-active';
|
||||
|
||||
// Current caller's promise.
|
||||
var currentPromise = null;
|
||||
|
||||
const hideModal = () => {
|
||||
currentPromise = null;
|
||||
$modal.classList.remove(cls);
|
||||
};
|
||||
|
||||
const showModal = ({
|
||||
message,
|
||||
title="Alert",
|
||||
isConfirm=false,
|
||||
buttons=["Ok", "Cancel"],
|
||||
}) => {
|
||||
$ok.innerHTML = buttons[0];
|
||||
$cancel.innerHTML = buttons[1];
|
||||
$cancel.style.display = isConfirm ? "" : "none";
|
||||
|
||||
// Strip HTML from message but allow line breaks.
|
||||
message = message.replace(/</g, "<");
|
||||
message = message.replace(/>/g, ">");
|
||||
message = message.replace(/\n/g, "<br>");
|
||||
|
||||
$title.innerHTML = (isConfirm ? confirmIcon : alertIcon) + title;
|
||||
$body.innerHTML = message;
|
||||
|
||||
// Show the modal.
|
||||
$modal.classList.add(cls);
|
||||
|
||||
// Focus the OK button, e.g. so hitting Enter doesn't accidentally (re)click the same
|
||||
// link/button which prompted the alert box in the first place.
|
||||
window.requestAnimationFrame(() => {
|
||||
$ok.focus();
|
||||
});
|
||||
|
||||
// Return as a promise.
|
||||
return new Promise((resolve, reject) => {
|
||||
currentPromise = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
// Click events for the modal buttons.
|
||||
$ok.addEventListener('click', (e) => {
|
||||
if (currentPromise !== null) {
|
||||
currentPromise();
|
||||
}
|
||||
hideModal();
|
||||
});
|
||||
$cancel.addEventListener('click', (e) => {
|
||||
hideModal();
|
||||
});
|
||||
|
||||
// Key bindings to dismiss the modal.
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if ($modal.classList.contains(cls)) {
|
||||
if (e.key == 'Enter') {
|
||||
$ok.click();
|
||||
} else if (e.key == 'Escape') {
|
||||
$cancel.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Inline submit button confirmation prompts, e.g.: many submit buttons have name="intent"
|
||||
// and want the user to confirm before submitting, and had inline onclick handlers.
|
||||
(document.querySelectorAll('.nonshy-confirm-submit') || []).forEach(button => {
|
||||
const message = button.dataset.confirm;
|
||||
if (!message) return;
|
||||
|
||||
const onclick = (e) => {
|
||||
e.preventDefault();
|
||||
modalConfirm({
|
||||
message: message.replace(/\\n/g, '\n'),
|
||||
}).then(() => {
|
||||
button.removeEventListener('click', onclick);
|
||||
window.requestAnimationFrame(() => {
|
||||
button.click();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
button.addEventListener('click', onclick);
|
||||
});
|
||||
|
||||
// Exported global functions to invoke the modal.
|
||||
window.modalAlert = async ({ message, title="Alert" }) => {
|
||||
return showModal({
|
||||
message,
|
||||
title,
|
||||
isConfirm: false,
|
||||
});
|
||||
};
|
||||
window.modalConfirm = async ({ message, title="Confirm", buttons=["Ok", "Cancel"] }) => {
|
||||
return showModal({
|
||||
message,
|
||||
title,
|
||||
isConfirm: true,
|
||||
buttons,
|
||||
});
|
||||
};
|
||||
});
|
|
@ -8,20 +8,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
// at the page header instead of going to the dedicated comment page.
|
||||
(document.querySelectorAll(".nonshy-quote-button") || []).forEach(node => {
|
||||
const message = node.dataset.quoteBody,
|
||||
replyTo = node.dataset.replyTo,
|
||||
commentID = node.dataset.commentId;
|
||||
|
||||
// If we have a comment ID, have the at-mention link to it.
|
||||
let atMention = "@" + replyTo;
|
||||
if (commentID) {
|
||||
atMention = `[@${replyTo}](/go/comment?id=${commentID})`;
|
||||
}
|
||||
replyTo = node.dataset.replyTo;
|
||||
|
||||
node.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (replyTo) {
|
||||
$message.value += atMention + "\n\n";
|
||||
$message.value += "@" + replyTo + "\n\n";
|
||||
}
|
||||
|
||||
// Prepare the quoted message.
|
||||
|
@ -37,18 +30,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
});
|
||||
|
||||
(document.querySelectorAll(".nonshy-reply-button") || []).forEach(node => {
|
||||
const replyTo = node.dataset.replyTo,
|
||||
commentID = node.dataset.commentId;
|
||||
|
||||
// If we have a comment ID, have the at-mention link to it.
|
||||
let atMention = "@" + replyTo;
|
||||
if (commentID) {
|
||||
atMention = `[@${replyTo}](/go/comment?id=${commentID})`;
|
||||
}
|
||||
const replyTo = node.dataset.replyTo;
|
||||
|
||||
node.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
$message.value += atMention + "\n\n";
|
||||
$message.value += "@" + replyTo + "\n\n";
|
||||
$message.scrollIntoView();
|
||||
$message.focus();
|
||||
});
|
||||
|
|
|
@ -43,7 +43,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.StatusCode !== 200) {
|
||||
modalAlert({message: data.data.error});
|
||||
window.alert(data.data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -54,7 +54,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
$label.innerHTML = `Like (${likes})`;
|
||||
}
|
||||
}).catch(resp => {
|
||||
console.error("Like:", resp);
|
||||
window.alert(resp);
|
||||
}).finally(() => {
|
||||
busy = false;
|
||||
})
|
||||
|
|
|
@ -85,16 +85,15 @@
|
|||
$dob = document.querySelector("#dob");
|
||||
|
||||
$manualEntry.addEventListener("click", function(e) {
|
||||
$manualEntry.blur();
|
||||
e.preventDefault();
|
||||
|
||||
let answer = window.prompt("Enter your birthdate in 'YYYY-MM-DD' format").trim().replace(/\//g, '-');
|
||||
if (answer.match(/^(\d{2})-(\d{2})-(\d{4})/)) {
|
||||
let group = answer.match(/^(\d{2})-(\d{2})-(\d{4})/);
|
||||
answer = `${group[3]}-${group[1]}-${group[2]}`;
|
||||
modalAlert({message: `NOTE: Your input was interpreted to be in MM/DD/YYYY order and has been read as: ${answer}`});
|
||||
window.alert(`NOTE: Your input was interpreted to be in MM/DD/YYYY order and has been read as: ${answer}`);
|
||||
} else if (!answer.match(/^\d{4}-\d{2}-\d{2}/)) {
|
||||
modalAlert({message: `Please enter the date in YYYY-MM-DD format.`});
|
||||
window.alert(`Please enter the date in YYYY-MM-DD format.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user