Compare commits

..

1 Commits

Author SHA1 Message Date
Noah Petherbridge
a0df988ffa WIP face detection experiment 2024-01-08 20:10:12 -08:00
2374 changed files with 12668 additions and 419129 deletions

1
.gitignore vendored
View File

@ -1,6 +1,5 @@
/nonshy /nonshy
/web/static/photos /web/static/photos
/coldstorage
database.sqlite database.sqlite
settings.json settings.json
pgdump.sql pgdump.sql

140
README.md
View File

@ -20,6 +20,33 @@ The website can also run out of a local SQLite database which is convenient
for local development. The production server runs on PostgreSQL and the for local development. The production server runs on PostgreSQL and the
web app is primarily designed for that. web app is primarily designed for that.
### PostGIS Extension for PostgreSQL
For the "Who's Nearby" feature to work you will need a PostgreSQL
database with the PostGIS geospatial extension installed. Usually
it might be a matter of `dnf install postgis` and activating the
extension on your nonshy database as your superuser (postgres):
```psql
create extension postgis;
```
If you get errors like "Type geography not found" from Postgres when
running distance based searches, this is the likely culprit.
### Face Detection
Fedora: `dnf install python3 python3-opencv opencv-data`
Debian: `apt install python3-opencv opencv-data`
If you get an error like:
> facedetect: error: cannot load HAAR_FRONTALFACE_ALT2 from /usr/share/opencv/haarcascades/haarcascade_frontalface_alt2.xml
Check whether the correct path on disk is actually /usr/share/opencv4 instead of /usr/share/opencv.
One solution then is to symlink the path correctly.
## Building the App ## Building the App
This app is written in Go: [go.dev](https://go.dev). You can probably This app is written in Go: [go.dev](https://go.dev). You can probably
@ -47,96 +74,6 @@ a database.
For simple local development, just set `"UseSQLite": true` and the For simple local development, just set `"UseSQLite": true` and the
app will run with a SQLite database. app will run with a SQLite database.
### Postgres is Highly Recommended
This website is intended to run under PostgreSQL and some of its
features leverage Postgres specific extensions. For quick local
development, SQLite will work fine but some website features will
be disabled and error messages given. These include:
* Location features such as "Who's Nearby" (PostGIS extension)
* "Newest" tab on the forums: to deduplicate comments by most recent
thread depends on Postgres, SQLite will always show all latest
comments without deduplication.
### PostGIS Extension for PostgreSQL
For the "Who's Nearby" feature to work you will need a PostgreSQL
database with the PostGIS geospatial extension installed. Usually
it might be a matter of `dnf install postgis` and activating the
extension on your nonshy database as your superuser (postgres):
```psql
create extension postgis;
```
If you get errors like "Type geography not found" from Postgres when
running distance based searches, this is the likely culprit.
### Signed Photo URLs (NGINX)
The website supports "signed photo" URLs that can help protect the direct
links to user photos (their /static/photos/*.jpg paths) to ensure only
logged-in and authorized users are able to access those links.
This feature is not enabled (enforcing) by default, as it relies on
cooperation with the NGINX reverse proxy server
(module ngx_http_auth_request).
In your NGINX config, set your /static/ path to leverage NGINX auth_request
like so:
```nginx
server {
# your boilerplate server info (SSL, etc.) - not relevant to this example.
listen 80 default_server;
listen [::]:80 default_server;
# Relevant: setting the /static/ URL on NGINX to be an alias to your local
# nonshy static folder on disk. In this example, the git clone for the
# website was at /home/www-user/git/nonshy/website, so that ./web/static/
# is the local path where static files (e.g., photos) are uploaded.
location /static/ {
# Important: auth_request tells NGINX to do subrequest authentication
# on requests into the /static/ URI of your website.
auth_request /static-auth;
# standard NGINX alias commands.
alias /home/www-user/git/nonshy/website/web/static/;
autoindex off;
}
# Configure the internal subrequest auth path.
# Note: the path "/static-auth" can be anything you want.
location = /static-auth {
internal; # this is an internal route for NGINX only, not public
# Proxy to the /v1/auth/static URL on the web app.
# This line assumes the website runs on localhost:8080.
proxy_pass http://localhost:8080/v1/auth/static;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
# Important: the X-Original-URI header tells the web app what the
# original path (e.g. /static/photos/*) was, so the web app knows
# which sub-URL to enforce authentication on.
proxy_set_header X-Original-URI $request_uri;
}
}
```
When your NGINX config is set up like the above, you can edit the
settings.json to mark SignedPhoto/Enabled=true, and restart the
website. Be sure to test it!
On a photo gallery view, all image URLs under /static/photos/ should
come with a ?jwt= parameter, and the image should load for the current
user. The JWT token is valid for 30 seconds after which the direct link
to the image should expire and give a 403 Forbidden response.
When this feature is NOT enabled/not enforcing: the jwt= parameter is
still generated on photo URLs but is not enforced by the web app.
## Usage ## Usage
The `nonshy` binary has sub-commands to either run the web server The `nonshy` binary has sub-commands to either run the web server
@ -202,15 +139,26 @@ the web app by using the admin controls on their profile page.
templates, issue redirects, error pages, ... templates, issue redirects, error pages, ...
* `pkg/utility`: miscellaneous useful functions for the app. * `pkg/utility`: miscellaneous useful functions for the app.
## Cron workers ## Cron API Endpoints
You can schedule the `nonshy vacuum` command in your crontab. This command In settings.json get or configure the CronAPIKey (a UUID4 value is good and
will check and clean up the database for things such as: orphaned comment the app generates a fresh one by default). The following are the cron API
photos (where somebody uploaded a photo to post on the forum, but then didn't endpoints that you may want to configure to run periodic maintenance tasks
finish creating their post). on the app, such as to remove orphaned comment photos.
### GET /v1/comment-photos/remove-orphaned
Query parameters: `apiKey` which is the CronAPIKey.
This endpoint removes orphaned CommentPhotos (photo attachments to forum
posts). An orphaned photo is one that has no CommentID and was uploaded
older than 24 hours ago; e.g. a user uploaded a picture but then did not
complete the posting of their comment.
Suggested crontab:
```cron ```cron
0 2 * * * cd /home/nonshy/git/website && ./nonshy vacuum 0 2 * * * curl "http://localhost:8080/v1/comment-photos/remove-orphaned?apiKey=X"
``` ```
## License ## License

View File

@ -6,11 +6,11 @@ import (
nonshy "code.nonshy.com/nonshy/website/pkg" nonshy "code.nonshy.com/nonshy/website/pkg"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/encryption/coldstorage"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/models/backfill" "code.nonshy.com/nonshy/website/pkg/models/backfill"
"code.nonshy.com/nonshy/website/pkg/models/exporting" "code.nonshy.com/nonshy/website/pkg/models/exporting"
"code.nonshy.com/nonshy/website/pkg/photo"
"code.nonshy.com/nonshy/website/pkg/redis" "code.nonshy.com/nonshy/website/pkg/redis"
"code.nonshy.com/nonshy/website/pkg/worker" "code.nonshy.com/nonshy/website/pkg/worker"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
@ -170,81 +170,6 @@ func main() {
}, },
}, },
}, },
{
Name: "coldstorage",
Usage: "cold storage functions for sensitive files",
Subcommands: []*cli.Command{
{
Name: "decrypt",
Usage: "decrypt a file from cold storage using the RSA private key",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "key",
Aliases: []string{"k"},
Required: true,
Usage: "RSA private key file for cold storage",
},
&cli.StringFlag{
Name: "aes",
Aliases: []string{"a"},
Required: true,
Usage: "AES key file used with the encrypted item in question (.aes file)",
},
&cli.StringFlag{
Name: "input",
Aliases: []string{"i"},
Required: true,
Usage: "input file to decrypt (.enc file)",
},
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Required: true,
Usage: "output file to write to (like a .jpg file)",
},
},
Action: func(c *cli.Context) error {
err := coldstorage.FileFromColdStorage(
c.String("key"),
c.String("aes"),
c.String("input"),
c.String("output"),
)
if err != nil {
log.Error("Error decrypting from cold storage: %s", err)
return err
}
log.Info("Wrote decrypted file to: %s", c.String("output"))
return nil
},
},
},
},
{
Name: "setup",
Usage: "setup and data import functions for the website",
Subcommands: []*cli.Command{
{
Name: "locations",
Usage: "import the database of world city locations from simplemaps.com",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "input",
Aliases: []string{"i"},
Required: true,
Usage: "the input worldcities.csv from simplemaps, with required headers: id, city, lat, lng, country, iso2",
},
},
Action: func(c *cli.Context) error {
initdb(c)
filename := c.String("input")
return models.InitializeWorldCities(filename)
},
},
},
},
{ {
Name: "backfill", Name: "backfill",
Usage: "One-off maintenance tasks and data backfills for database migrations", Usage: "One-off maintenance tasks and data backfills for database migrations",
@ -264,35 +189,6 @@ func main() {
return nil 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
},
},
},
},
{
Name: "vacuum",
Usage: "Run database maintenance tasks (clean up broken links, remove orphaned comment photos, etc.) for data consistency.",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "dryrun",
Usage: "don't actually delete anything",
},
},
Action: func(c *cli.Context) error {
initdb(c)
return worker.Vacuum(c.Bool("dryrun"))
}, },
}, },
}, },
@ -334,6 +230,9 @@ func initdb(c *cli.Context) {
// Auto-migrate the DB. // Auto-migrate the DB.
models.AutoMigrate() models.AutoMigrate()
// Initialize FaceScore face detection.
photo.InitFaceScore()
} }
func initcache(c *cli.Context) { func initcache(c *cli.Context) {

View File

@ -1,70 +0,0 @@
# Cold Storage
One of the security features of the website is **cold storage** which implements a "one way" encryption process for archiving sensitive files on the site.
The first use case is to archive secondary photo IDs: if a user was requested to provide a scan of their government issued photo ID for approval, the site can archive the original copy to cold storage when approved in case of any future inquiry.
The cold storage feature works by encrypting the file using an RSA public key, and relies on the matching private key to be **removed** from the web server and kept offline; so in case of a hack or data breach, the key that can decrypt the cold storage files will **NOT** be kept on the same web server.
This document explains how this feature works and how to configure it.
## Initialization
When the server starts up and there is not a cold storage RSA key configured, the feature will be initialized by generating new RSA encryption keys:
* The directory `./coldstorage/keys` is created and the RSA keys will be written in files named **private.pem** and **public.pem**.
* The RSA public key is _also_ written into the **settings.json** file for the server, at the Encryption / ColdStorageRSAPublicKey property.
You should **move the keys OFF of your web server machine** and keep them safe for your bookkeeping. Notably, the `private.pem` key is the sensitive file that should be removed.
The app does not need either of these keys to remain on the server: the settings.json has a copy of the RSA public key which the app uses to create cold storage encrypted files.
### Admin Dashboard Warning
As a safety precaution: if the private.pem key remains on disk, a warning is shown at the top of the Admin Dashboard page of the website to remind you that the key should be removed and stored safely offline.
## Recovering from Cold Storage
Should you need to recover an encrypted file from cold storage, the `nonshy coldstorage decrypt` command built into the Go server binary has the function to decrypt the files.
Every item that is moved into cold storage generates two files: an encrypted AES key file (`.aes`) and the encrypted data file itself (with a `.enc` extension). For example, a "photo.jpg" might go into cold storage as two files: "photo.jpg.aes" and "photo.jpg.enc"
You will need the following three files to decrypt from cold storage:
1. The RSA private key file (private.pem)
2. The encrypted AES key file (.aes extension)
3. The encrypted cold storage data file (.enc extension)
The command to decrypt them is thus like:
```bash
# command example
nonshy coldstorage decrypt \
--key private.pem \
--aes photo.jpg.aes \
--input photo.jpg.enc \
--output photo.jpg
# short command line flags work too
nonshy coldstorage decrypt -k private.pem \
-a photo.jpg.aes -i photo.jpg.enc \
-o photo.jpg
```
The `--output` file is where the decrypted file will be written to.
## Encryption Algorithms
When a file is moved into cold storage:
1. A fresh new AES symmetric key is generated from scratch.
2. The AES key is encrypted using the **RSA public key** and written to the ".aes" file in the coldstorage/ folder.
3. The original file is encrypted using that AES symmetric key and written to the ".enc" file in the coldstorage/ folder.
At the end of the encrypt function: the web server no longer has the AES key and is _unable_ to decrypt it because the private key is not available (as it should be kept offline for security).
Decrypting a file out of cold storage is done like so:
1. The encrypted AES key is unlocked using the **RSA private key**.
2. The encrypted cold storage file (.enc) is decrypted with that AES key.
3. The cleartext data is written to the output file.

84
go.mod
View File

@ -1,65 +1,65 @@
module code.nonshy.com/nonshy/website module code.nonshy.com/nonshy/website
go 1.22 go 1.18
toolchain go1.22.0
require ( require (
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b
github.com/SherClockHolmes/webpush-go v1.3.0
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f
github.com/go-redis/redis/v8 v8.11.5 github.com/go-redis/redis/v8 v8.11.5
github.com/golang-jwt/jwt/v4 v4.5.0 github.com/google/uuid v1.3.0
github.com/google/uuid v1.6.0 github.com/urfave/cli/v2 v2.11.1
github.com/microcosm-cc/bluemonday v1.0.26 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
github.com/oschwald/geoip2-golang v1.9.0 gorm.io/driver/postgres v1.3.8
github.com/pquerna/otp v1.4.0 gorm.io/driver/sqlite v1.3.6
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 gorm.io/gorm v1.23.8
github.com/urfave/cli/v2 v2.27.1
golang.org/x/crypto v0.19.0
golang.org/x/image v0.15.0
golang.org/x/text v0.14.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gorm.io/driver/postgres v1.5.6
gorm.io/driver/sqlite v1.5.5
gorm.io/gorm v1.25.7
) )
require ( require (
github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/boombuler/barcode v1.0.1 // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/disintegration/imaging v1.6.2 // indirect github.com/disintegration/imaging v1.6.2 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/esimov/pigo v1.4.6 // indirect
github.com/go-redis/redis v6.15.9+incompatible // indirect
github.com/golang-jwt/jwt/v4 v4.4.3 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.12.1 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect github.com/jackc/pgproto3/v2 v2.3.0 // indirect
github.com/jackc/pgx/v5 v5.5.3 // indirect github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jackc/pgtype v1.11.0 // indirect
github.com/jackc/pgx/v4 v4.16.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mattn/go-sqlite3 v1.14.14 // indirect
github.com/oschwald/maxminddb-golang v1.12.0 // indirect github.com/microcosm-cc/bluemonday v1.0.19 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/oschwald/geoip2-golang v1.9.0 // indirect
github.com/russross/blackfriday v1.6.0 // indirect github.com/oschwald/maxminddb-golang v1.11.0 // indirect
github.com/pquerna/otp v1.4.0 // indirect
github.com/russross/blackfriday v1.5.2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/sergi/go-diff v1.3.1 // indirect github.com/sergi/go-diff v1.2.0 // indirect
github.com/shurcooL/go v0.0.0-20230706063926-5fe729b41b3a // indirect github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 // indirect
github.com/shurcooL/go-goon v1.0.0 // indirect github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480 // indirect
github.com/shurcooL/highlight_diff v0.0.0-20230708024848-22f825814995 // indirect github.com/shurcooL/highlight_go v0.0.0-20191220051317-782971ddf21b // indirect
github.com/shurcooL/highlight_go v0.0.0-20230708025100-33e05792540a // indirect github.com/shurcooL/octicon v0.0.0-20191102190552-cbb32d6a785c // indirect
github.com/shurcooL/octicon v0.0.0-20230705024016-66bff059edb8 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d // indirect github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d // indirect
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
golang.org/x/net v0.21.0 // indirect golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 // indirect
golang.org/x/sync v0.6.0 // indirect golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect
golang.org/x/sys v0.17.0 // indirect golang.org/x/sys v0.9.0 // indirect
golang.org/x/term v0.17.0 // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/tools v0.1.12 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect
) )

314
go.sum
View File

@ -1,18 +1,22 @@
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b h1:TDxEEWOJqMzsu9JW8/QgmT1lgQ9WD2KWlb2lKN/Ql2o= git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b h1:TDxEEWOJqMzsu9JW8/QgmT1lgQ9WD2KWlb2lKN/Ql2o=
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b/go.mod h1:jl+Qr58W3Op7OCxIYIT+b42jq8xFncJXzPufhrvza7Y= git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b/go.mod h1:jl+Qr58W3Op7OCxIYIT+b42jq8xFncJXzPufhrvza7Y=
github.com/SherClockHolmes/webpush-go v1.3.0 h1:CAu3FvEE9QS4drc3iKNgpBWFfGqNthKlZhp5QpYnu6k= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/SherClockHolmes/webpush-go v1.3.0/go.mod h1:AxRHmJuYwKGG1PVgYzToik1lphQvDnqFYDqimHvwhIw= github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e h1:lqIUFzxaqyYqUn4MhzAvSAh4wIte/iLNcIEWxpT/qbc=
github.com/Kagami/go-face v0.0.0-20210630145111-0c14797b4d0e/go.mod h1:9wdDJkRgo3SGTcFwbQ7elVIQhIr2bbBjecuY7VoqmPU=
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
@ -20,162 +24,260 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f h1:RMnUwTnNR070mFAEIoqMYjNirHj8i0h79VXTYyBCyVA= github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f h1:RMnUwTnNR070mFAEIoqMYjNirHj8i0h79VXTYyBCyVA=
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc= github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/esimov/pigo v1.4.6 h1:wpB9FstbqeGP/CZP+nTR52tUJe7XErq8buG+k4xCXlw=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/esimov/pigo v1.4.6/go.mod h1:uqj9Y3+3IRYhFK071rxz1QYq0ePhA6+R9jrUZavi46M=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgconn v1.12.1 h1:rsDFzIpRk7xT4B8FufgpCCeyjdNpKyghZeSefViE5W8=
github.com/jackc/pgconn v1.12.1/go.mod h1:ZkhRC59Llhrq3oSfrikvwQ5NaxYExr6twkdkMLaKono=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
github.com/jackc/pgx/v5 v5.5.3 h1:Ces6/M3wbDXYpM8JyyPD57ivTtJACFZJd885pdIaV2s= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
github.com/jackc/pgx/v5 v5.5.3/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.3.0 h1:brH0pCGBDkBW07HWlN/oSBXrmo3WB0UvZd1pIuDcL8Y=
github.com/jackc/pgproto3/v2 v2.3.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
github.com/jackc/pgtype v1.11.0 h1:u4uiGPz/1hryuXzyaBhSk6dnIyyG2683olG2OV+UUgs=
github.com/jackc/pgtype v1.11.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
github.com/jackc/pgx/v4 v4.16.1 h1:JzTglcal01DrghUqt+PmzWsZx/Yh7SC/CTQmSBMTd0Y=
github.com/jackc/pgx/v4 v4.16.1/go.mod h1:SIhx0D5hoADaiXZVyv+3gSm3LCIIINTVO0PficsvWGQ=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.2.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw=
github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/microcosm-cc/bluemonday v1.0.19 h1:OI7hoF5FY4pFz2VA//RN8TfM0YJ2dJcl4P4APrCWy6c=
github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE=
github.com/oschwald/geoip2-golang v1.9.0 h1:uvD3O6fXAXs+usU+UGExshpdP13GAqp4GBrzN7IgKZc= github.com/oschwald/geoip2-golang v1.9.0 h1:uvD3O6fXAXs+usU+UGExshpdP13GAqp4GBrzN7IgKZc=
github.com/oschwald/geoip2-golang v1.9.0/go.mod h1:BHK6TvDyATVQhKNbQBdrj9eAvuwOMi2zSFXizL3K81Y= github.com/oschwald/geoip2-golang v1.9.0/go.mod h1:BHK6TvDyATVQhKNbQBdrj9eAvuwOMi2zSFXizL3K81Y=
github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs= github.com/oschwald/maxminddb-golang v1.11.0 h1:aSXMqYR/EPNjGE8epgqwDay+P30hCBZIveY0WZbAWh0=
github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY= github.com/oschwald/maxminddb-golang v1.11.0/go.mod h1:YmVI+H0zh3ySFR3w+oz8PCfglAFj3PuCmui13+P9zDg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww=
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 h1:86e54L0i3pH3dAIA8OxBbfLrVyhoGpnNk1iJCigAWYs= github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 h1:86e54L0i3pH3dAIA8OxBbfLrVyhoGpnNk1iJCigAWYs=
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0=
github.com/shurcooL/go v0.0.0-20230706063926-5fe729b41b3a h1:ZHfoO7ZJhws9NU1kzZhStUnnVQiPtDe1PzpUnc6HirU= github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480 h1:KaKXZldeYH73dpQL+Nr38j1r5BgpAYQjYvENOUpIZDQ=
github.com/shurcooL/go v0.0.0-20230706063926-5fe729b41b3a/go.mod h1:DNrlr0AR9NsHD/aoc2pPeu4uSBZ/71yCHkR42yrzW3M= github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU=
github.com/shurcooL/go-goon v1.0.0 h1:BCQPvxGkHHJ4WpBO4m/9FXbITVIsvAm/T66cCcCGI7E= github.com/shurcooL/highlight_go v0.0.0-20191220051317-782971ddf21b h1:rBIwpb5ggtqf0uZZY5BPs1sL7njUMM7I8qD2jiou70E=
github.com/shurcooL/go-goon v1.0.0/go.mod h1:2wTHMsGo7qnpmqA8ADYZtP4I1DD94JpXGQ3Dxq2YQ5w= github.com/shurcooL/highlight_go v0.0.0-20191220051317-782971ddf21b/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag=
github.com/shurcooL/highlight_diff v0.0.0-20230708024848-22f825814995 h1:/6Fa0HAouqks/nlr3C3sv7KNDqutP3CM/MYz225uO28= github.com/shurcooL/octicon v0.0.0-20191102190552-cbb32d6a785c h1:p3w+lTqXulfa3aDeycxmcLJDNxyUB89gf2/XqqK3eO0=
github.com/shurcooL/highlight_diff v0.0.0-20230708024848-22f825814995/go.mod h1:eqklBUMsamqZbxXhhr6GafgswFTa5Aq12VQ0I2lnCR8= github.com/shurcooL/octicon v0.0.0-20191102190552-cbb32d6a785c/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ=
github.com/shurcooL/highlight_go v0.0.0-20230708025100-33e05792540a h1:aMmA4ghJXuzwIS/mEK+bf7U2WZECRxa3sPgR4QHj8Hw=
github.com/shurcooL/highlight_go v0.0.0-20230708025100-33e05792540a/go.mod h1:kLtotffsKtKsCupV8wNnNwQQHBccB1Oy5VSg8P409Go=
github.com/shurcooL/octicon v0.0.0-20230705024016-66bff059edb8 h1:W5meM/5DP0Igf+pS3Se363Y2DoDv9LUuZgQ24uG9LNY=
github.com/shurcooL/octicon v0.0.0-20230705024016-66bff059edb8/go.mod h1:hWBWTvIJ918VxbNOk2hxQg1/5j1M9yQI1Kp8d9qrOq8=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d h1:yKm7XZV6j9Ev6lojP2XaIshpT4ymkqhMeSghO5Ps00E= github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d h1:yKm7XZV6j9Ev6lojP2XaIshpT4ymkqhMeSghO5Ps00E=
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE=
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e h1:qpG93cPwA5f7s/ZPBJnGOYQNK/vKsaDaseuKT5Asee8= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e h1:qpG93cPwA5f7s/ZPBJnGOYQNK/vKsaDaseuKT5Asee8=
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e h1:Ee+VZw13r9NTOMnwTPs6O5KZ0MJU54hsxu9FpZ4pQ10= github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e h1:Ee+VZw13r9NTOMnwTPs6O5KZ0MJU54hsxu9FpZ4pQ10=
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e/go.mod h1:fSIW/szJHsRts/4U8wlMPhs+YqJC+7NYR+Qqb1uJVpA= github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e/go.mod h1:fSIW/szJHsRts/4U8wlMPhs+YqJC+7NYR+Qqb1uJVpA=
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= github.com/urfave/cli/v2 v2.11.1 h1:UKK6SP7fV3eKOefbS87iT9YHefv7iB/53ih6e+GNAsE=
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= github.com/urfave/cli/v2 v2.11.1/go.mod h1:f8iq5LtQ/bLxafbdBSLPPNsgaW0l/2fYYEHhAyPlwvo=
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c=
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 h1:/eM0PCrQI2xd471rI+snWuu251/+/jpBpZqir2mPdnU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/image v0.0.0-20220722155232-062f8c9fd539/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201107080550-4d91cf3a1aaf/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= golang.org/x/term v0.0.0-20191110171634-ad39bd3f0407/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gorm.io/driver/postgres v1.3.8 h1:8bEphSAB69t3odsCR4NDzt581iZEWQuRM27Cg6KgfPY=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.3.8/go.mod h1:qB98Aj6AhRO/oyu/jmZsi/YM9g6UzVCjMxO/6frFvcA=
gorm.io/driver/postgres v1.5.6 h1:ydr9xEd5YAM0vxVDY0X139dyzNz10spDiDlC7+ibLeU= gorm.io/driver/sqlite v1.3.6 h1:Fi8xNYCUplOqWiPa3/GuCeowRNBRGTf62DEmhMDHeQQ=
gorm.io/driver/postgres v1.5.6/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= gorm.io/driver/sqlite v1.3.6/go.mod h1:Sg1/pvnKtbQ7jLXxfZa+jSHvoX8hoZA8cn4xllOMTgE=
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E= gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE= gorm.io/gorm v1.23.6/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A= gorm.io/gorm v1.23.8 h1:h8sGJ+biDgBA1AD1Ha9gFCx7h8npU7AsLdlkX0n2TpE=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=

View File

@ -1,224 +0,0 @@
package chat
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
)
// MaybeDisconnectUser may send a DisconnectUserNow to BareRTC if the user should not be allowed in the chat room.
//
// For example, they have set their profile to private and become a shy account, or they deactivated or got banned.
//
// If the user is presently in the chat room, they will be removed and given an appropriate ChatServer message.
//
// Returns a boolean OK (they were online in chat, and were removed) with the error only returning in case of a
// communication or JSON encode error with BareRTC. If they were online and removed, an admin feedback notice is
// also generated for visibility and confirmation of success.
func MaybeDisconnectUser(user *models.User) (bool, error) {
// What reason to remove them? If a message is provided, the DisconnectUserNow API will be called.
var because = "You have been signed out of chat because "
var reasons = []struct {
If bool
Message string
}{
{
If: !user.Certified,
Message: because + "your nonshy account is not Certified, or its Certified status has been revoked.",
},
{
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>" +
"Please see the <a href=\"https://www.nonshy.com/faq#shy-faqs\">Shy Account FAQ</a> for more information.",
},
{
If: user.Status == models.UserStatusDisabled,
Message: because + "you have deactivated your nonshy account.",
},
{
If: user.Status == models.UserStatusBanned,
Message: because + "your nonshy account has been banned.",
},
{
// Catch-all for any non-active user status.
If: user.Status != models.UserStatusActive,
Message: because + "your nonshy account is no longer eligible to remain in the chat room.",
},
}
for _, reason := range reasons {
if reason.If {
i, err := DisconnectUserNow(user, reason.Message)
if err != nil {
return false, err
}
// Were they online and were removed? Notify the admin for visibility.
if i > 0 {
fb := &models.Feedback{
Intent: "report",
Subject: "Auto-Disconnect from Chat",
UserID: user.ID,
TableName: "users",
TableID: user.ID,
Message: fmt.Sprintf(
"A user was automatically disconnected from the chat room!\n\n"+
"* Username: %s\n"+
"* Number of users removed: %d\n"+
"* Message sent to them: %s\n\n"+
"Note: this is an informative message only. Users are expected to be removed from "+
"chat when they do things such as deactivate their account, or private their profile "+
"or pictures, and thus become ineligible to remain in the chat room.",
user.Username,
i,
reason.Message,
),
}
// Save the feedback.
if err := models.CreateFeedback(fb); err != nil {
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
}
}
// First removal reason wins.
break
}
}
return false, nil
}
// DisconnectUserNow tells the chat room to remove the user now if they are presently online.
func DisconnectUserNow(user *models.User, message string) (int, error) {
// API request struct for BareRTC /api/block/now endpoint.
var request = struct {
APIKey string
Usernames []string
Message string
Kick bool
}{
APIKey: config.Current.CronAPIKey,
Usernames: []string{
user.Username,
},
Message: message,
Kick: false,
}
type response struct {
OK bool
Removed int
Error string `json:",omitempty"`
}
// JSON request body.
jsonStr, err := json.Marshal(request)
if err != nil {
return 0, err
}
// Make the API request to BareRTC.
var url = strings.TrimSuffix(config.Current.BareRTC.URL, "/") + "/api/disconnect/now"
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
// Ingest the JSON response to see the count and error.
var (
result response
body, _ = io.ReadAll(resp.Body)
)
err = json.Unmarshal(body, &result)
if err != nil {
return 0, err
}
if resp.StatusCode != http.StatusOK || !result.OK {
log.Error("DisconnectUserNow: error from BareRTC: status %d body %s", resp.StatusCode, body)
return result.Removed, errors.New(result.Error)
}
return result.Removed, nil
}
// EraseChatHistory tells the chat room to clear DMs history for this user.
func EraseChatHistory(username string) (int, error) {
// API request struct for BareRTC /api/message/clear endpoint.
var request = struct {
APIKey string
Username string
}{
APIKey: config.Current.CronAPIKey,
Username: username,
}
type response struct {
OK bool
MessagesErased int
Error string `json:",omitempty"`
}
// JSON request body.
jsonStr, err := json.Marshal(request)
if err != nil {
return 0, err
}
// Make the API request to BareRTC.
var url = strings.TrimSuffix(config.Current.BareRTC.URL, "/") + "/api/message/clear"
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonStr))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
// Ingest the JSON response to see the count and error.
var (
result response
body, _ = io.ReadAll(resp.Body)
)
err = json.Unmarshal(body, &result)
if err != nil {
return 0, err
}
if resp.StatusCode != http.StatusOK || !result.OK {
log.Error("EraseChatHistory: error from BareRTC: status %d body %s", resp.StatusCode, body)
return result.MessagesErased, errors.New(result.Error)
}
return result.MessagesErased, nil
}

View File

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

View File

@ -6,9 +6,11 @@ const (
// - Chat: have operator controls in the chat room // - Chat: have operator controls in the chat room
// - Forum: ability to edit and delete user posts // - Forum: ability to edit and delete user posts
// - Photo: omniscient view of all gallery photos, can edit/delete photos // - Photo: omniscient view of all gallery photos, can edit/delete photos
ScopeChatModerator = "social.moderator.chat" // - Inner circle: ability to remove users from it
ScopeForumModerator = "social.moderator.forum" ScopeChatModerator = "social.moderator.chat"
ScopePhotoModerator = "social.moderator.photo" ScopeForumModerator = "social.moderator.forum"
ScopePhotoModerator = "social.moderator.photo"
ScopeCircleModerator = "social.moderator.inner-circle"
// Certification photo management // Certification photo management
// - Approve: ability to respond to pending certification pics // - Approve: ability to respond to pending certification pics
@ -30,53 +32,21 @@ const (
// - Impersonate: ability to log in as a user account // - Impersonate: ability to log in as a user account
// - Ban: ability to ban/unban users // - Ban: ability to ban/unban users
// - Delete: ability to delete user accounts // - Delete: ability to delete user accounts
ScopeUserCreate = "admin.user.create"
ScopeUserInsight = "admin.user.insights" ScopeUserInsight = "admin.user.insights"
ScopeUserImpersonate = "admin.user.impersonate" ScopeUserImpersonate = "admin.user.impersonate"
ScopeUserBan = "admin.user.ban" ScopeUserBan = "admin.user.ban"
ScopeUserPassword = "admin.user.password"
ScopeUserDelete = "admin.user.delete"
ScopeUserPromote = "admin.user.promote" ScopeUserPromote = "admin.user.promote"
ScopeUserDelete = "admin.user.delete"
// Other admin views
ScopeFeedbackAndReports = "admin.feedback"
ScopeChangeLog = "admin.changelog"
ScopeUserNotes = "admin.user.notes"
// Admins with this scope can not be blocked by users. // Admins with this scope can not be blocked by users.
ScopeUnblockable = "admin.unblockable" ScopeUnblockable = "admin.unblockable"
// The global wildcard scope gets all available permissions. // Special scope to mark an admin automagically in the Inner Circle
ScopeSuperuser = "*" ScopeIsInnerCircle = "admin.override.inner-circle"
) )
// Friendly description for each scope.
var AdminScopeDescriptions = map[string]string{
ScopeChatModerator: "Have operator controls in the chat room (can mark cameras as explicit, or kick/ban people from chat).",
ScopeForumModerator: "Ability to moderate the forum (edit or delete posts).",
ScopePhotoModerator: "Ability to moderate photo galleries (can see all private or friends-only photos, and edit or delete them).",
ScopeCertificationApprove: "Ability to see pending certification pictures and approve or reject them.",
ScopeCertificationList: "Ability to see existing certification pictures that have already been approved or rejected.",
ScopeCertificationView: "Ability to see and double check a specific user's certification picture on demand.",
ScopeForumAdmin: "Ability to manage forums themselves (add or remove forums, edit their properties).",
ScopeAdminScopeAdmin: "Ability to manage admin permissions for other admin accounts.",
ScopeMaintenance: "Ability to activate maintenance mode functions of the website (turn features on or off, disable signups or logins, etc.)",
ScopeUserCreate: "Ability to manually create a new user account, bypassing the signup page.",
ScopeUserInsight: "Ability to see admin insights about a user profile (e.g. their block lists and who blocks them).",
ScopeUserImpersonate: "Ability to log in as any user account (note: this action is logged and notifies all admins when it happens. Admins must write a reason and it is used to diagnose customer support issues, help with their certification picture, or investigate a reported Direct Message conversation they had).",
ScopeUserBan: "Ability to ban or unban user accounts.",
ScopeUserPassword: "Ability to reset a user's password on their behalf.",
ScopeUserDelete: "Ability to fully delete user accounts on their behalf.",
ScopeUserPromote: "Ability to add or remove the admin status flag on a user profile.",
ScopeFeedbackAndReports: "Ability to see admin reports and user feedback.",
ScopeChangeLog: "Ability to see website change logs (e.g. history of a certification photo, gallery photo settings, etc.)",
ScopeUserNotes: "Ability to see all notes written about a user, or to see all notes written by admins.",
ScopeUnblockable: "This admin can not be added to user block lists.",
ScopeSuperuser: "This admin has access to ALL admin features on the website.",
}
// Number of expected scopes for unit test and validation. // Number of expected scopes for unit test and validation.
const QuantityAdminScopes = 20 const QuantityAdminScopes = 16
// The specially named Superusers group. // The specially named Superusers group.
const AdminGroupSuperusers = "Superusers" const AdminGroupSuperusers = "Superusers"
@ -87,26 +57,18 @@ func ListAdminScopes() []string {
ScopeChatModerator, ScopeChatModerator,
ScopeForumModerator, ScopeForumModerator,
ScopePhotoModerator, ScopePhotoModerator,
ScopeCircleModerator,
ScopeCertificationApprove, ScopeCertificationApprove,
ScopeCertificationList, ScopeCertificationList,
ScopeCertificationView, ScopeCertificationView,
ScopeForumAdmin, ScopeForumAdmin,
ScopeAdminScopeAdmin, ScopeAdminScopeAdmin,
ScopeMaintenance,
ScopeUserCreate,
ScopeUserInsight, ScopeUserInsight,
ScopeUserImpersonate, ScopeUserImpersonate,
ScopeUserBan, ScopeUserBan,
ScopeUserPassword,
ScopeUserDelete, ScopeUserDelete,
ScopeUserPromote, ScopeUserPromote,
ScopeFeedbackAndReports,
ScopeChangeLog,
ScopeUserNotes,
ScopeUnblockable, ScopeUnblockable,
ScopeIsInnerCircle,
} }
} }
func AdminScopeDescription(scope string) string {
return AdminScopeDescriptions[scope]
}

View File

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

View File

@ -25,10 +25,6 @@ const (
PhotoDiskPath = "./web/static/photos" PhotoDiskPath = "./web/static/photos"
) )
// PhotoURLRegexp describes an image path under "/static/photos" that can be parsed from Markdown or HTML input.
// It is used by e.g. the ReSignURLs function - if you move image URLs to a CDN this may need updating.
var PhotoURLRegexp = regexp.MustCompile(`(?:['"])(/static/photos/[^'"\s?]+(?:\?[^'"\s]*)?)(?:['"]|[^'"\s]*)`)
// Security // Security
const ( const (
BcryptCost = 14 BcryptCost = 14
@ -42,11 +38,6 @@ const (
TwoFactorBackupCodeCount = 12 TwoFactorBackupCodeCount = 12
TwoFactorBackupCodeLength = 8 // characters a-z0-9 TwoFactorBackupCodeLength = 8 // characters a-z0-9
// Signed URLs for static photo authentication.
SignedPhotoJWTExpires = 30 * time.Second // Regular, per-user, short window
SignedPublicAvatarJWTExpires = 7 * 24 * time.Hour // Widely public, e.g. chat room
SignedPublicAvatarUsername = "@" // JWT 'username' for widely public JWT
) )
// Authentication // Authentication
@ -60,11 +51,6 @@ const (
ChangeEmailRedisKey = "change-email/%s" ChangeEmailRedisKey = "change-email/%s"
SignupTokenExpires = 24 * time.Hour // used for all tokens so far SignupTokenExpires = 24 * time.Hour // used for all tokens so far
// How to rate limit same types of emails being delivered, e.g.
// signups, cert approvals (double post), etc.
EmailDebounceDefault = 24 * time.Hour // default debounce per type of email
EmailDebounceResetPassword = 4 * time.Hour // "forgot password" emails debounce
// Rate limits // Rate limits
RateLimitRedisKey = "rate-limit/%s/%s" // namespace, id RateLimitRedisKey = "rate-limit/%s/%s" // namespace, id
LoginRateLimitWindow = 1 * time.Hour LoginRateLimitWindow = 1 * time.Hour
@ -80,25 +66,15 @@ const (
ContactRateLimitCooldownAt = 1 ContactRateLimitCooldownAt = 1
ContactRateLimitCooldown = 2 * time.Minute ContactRateLimitCooldown = 2 * time.Minute
// "Mark Explicit" rate limit to curb a mischievous user just bulk marking the
// whole gallery as explicit.
MarkExplicitRateLimitWindow = 1 * time.Hour
MarkExplicitRateLimit = 20 // 10 failed MarkExplicit attempts = locked for full hour
MarkExplicitRateLimitCooldownAt = 10 // 10 photos in an hour, start throttling.
MarkExplicitRateLimitCooldown = time.Minute
// How frequently to refresh LastLoginAt since sessions are long-lived. // How frequently to refresh LastLoginAt since sessions are long-lived.
LastLoginAtCooldown = time.Hour LastLoginAtCooldown = time.Hour
// Chat room status refresh interval. // Chat room status refresh interval.
ChatStatusRefreshInterval = 30 * time.Second ChatStatusRefreshInterval = 30 * time.Second
// Cache TTL for the demographics page.
DemographicsCacheTTL = time.Hour
) )
var ( var (
UsernameRegexp = regexp.MustCompile(`^[a-z0-9_.-]{3,32}$`) UsernameRegexp = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
ReservedUsernames = []string{ ReservedUsernames = []string{
"admin", "admin",
"admins", "admins",
@ -118,24 +94,20 @@ var (
const ( const (
MaxPhotoWidth = 1280 MaxPhotoWidth = 1280
ProfilePhotoWidth = 512 ProfilePhotoWidth = 512
AltTextMaxLength = 5000
// Quotas for uploaded photos. // Quotas for uploaded photos.
PhotoQuotaUncertified = 6 PhotoQuotaUncertified = 6
PhotoQuotaCertified = 100 PhotoQuotaCertified = 100
// Min number of public photos for inner circle members to see the prompt to invite.
InnerCircleMinimumPublicPhotos = 5
// Rate limit for too many Site Gallery pictures. // Rate limit for too many Site Gallery pictures.
// Some users sign up and immediately max out their gallery and spam // Some users sign up and immediately max out their gallery and spam
// the Site Gallery page. These limits can ensure only a few Site Gallery // the Site Gallery page. These limits can ensure only a few Site Gallery
// pictures can be posted per day. // pictures can be posted per day.
SiteGalleryRateLimitMax = 5 SiteGalleryRateLimitMax = 5
SiteGalleryRateLimitInterval = 24 * time.Hour SiteGalleryRateLimitInterval = 24 * time.Hour
// Only ++ the Views count per user per photo within a small
// window of time - if a user keeps reloading the same photo
// rapidly it does not increment the view counter more.
PhotoViewDebounceRedisKey = "debounce-view/user=%d/photoid=%d"
PhotoViewDebounceCooldown = 1 * time.Hour
) )
// Forum settings // Forum settings
@ -145,27 +117,6 @@ const (
// rapidly it does not increment the view counter more. // rapidly it does not increment the view counter more.
ThreadViewDebounceRedisKey = "debounce-view/user=%d/thr=%d" ThreadViewDebounceRedisKey = "debounce-view/user=%d/thr=%d"
ThreadViewDebounceCooldown = 1 * time.Hour ThreadViewDebounceCooldown = 1 * time.Hour
// Enable user-owned forums (feature flag)
UserForumsEnabled = true
)
// User-Owned Forums: Quota settings for how many forums a user can own.
var (
// They get one forum after they've been Certified for 45 days.
UserForumQuotaCertLifetimeDays = time.Hour * 24 * 45
// Schedule for gaining additional quota for a number of comments written
// on any forum thread. The user must have the sum of all of these post
// counts to gain one forum per level.
UserForumQuotaCommentCountSchedule = []int64{
10, // Get a forum after your first 10 posts.
20, // Get a 2nd forum after 20 additional posts (30 total)
30, // 30 more posts (60 total)
60, // 60 more posts (120 total)
80, // 80 more posts (200 total)
100, // and then one new forum for every 100 additional posts
}
) )
// Poll settings // Poll settings

View File

@ -1,7 +1,5 @@
package config package config
import "regexp"
// Various hard-coded enums such as choice of gender, sexuality, relationship status etc. // Various hard-coded enums such as choice of gender, sexuality, relationship status etc.
var ( var (
MaritalStatus = []string{ MaritalStatus = []string{
@ -34,8 +32,6 @@ var (
"Gay", "Gay",
"Bisexual", "Bisexual",
"Bicurious", "Bicurious",
"Pansexual",
"Asexual",
} }
HereFor = []string{ HereFor = []string{
@ -68,18 +64,12 @@ var (
"music_movies", "music_movies",
"hide_age", "hide_age",
} }
EssayProfileFields = []string{
"about_me",
"interests",
"music_movies",
}
// Site preference names (stored in ProfileField table) // Site preference names (stored in ProfileField table)
SitePreferenceFields = []string{ SitePreferenceFields = []string{
"dm_privacy", "dm_privacy",
"blur_explicit", "blur_explicit",
"site_gallery_default", // default view on site gallery (friends-only or all certified?) "site_gallery_default", // default view on site gallery (friends-only or all certified?)
"chat_moderation_rules",
} }
// Choices for the Contact Us subject // Choices for the Contact Us subject
@ -100,8 +90,6 @@ var (
{"report.photo", "Report a problematic photo"}, {"report.photo", "Report a problematic photo"},
{"report.message", "Report a direct message conversation"}, {"report.message", "Report a direct message conversation"},
{"report.comment", "Report a forum post or comment"}, {"report.comment", "Report a forum post or comment"},
{"report.forum", "Report a forum or community"},
{"forum.adopt", "Adopt a forum"},
}, },
}, },
} }
@ -109,41 +97,12 @@ var (
// Default forum categories for forum landing page. // Default forum categories for forum landing page.
ForumCategories = []string{ ForumCategories = []string{
"Rules and Announcements", "Rules and Announcements",
"The Inner Circle",
"Nudists", "Nudists",
"Exhibitionists", "Exhibitionists",
"Photo Boards", "Photo Boards",
"Anything Goes", "Anything Goes",
} }
// 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.",
},
}
) )
// ContactUs choices for the subject drop-down. // ContactUs choices for the subject drop-down.
@ -158,13 +117,6 @@ type Option struct {
Label string Label string
} }
// ChecklistOption for checkbox-lists.
type ChecklistOption struct {
Value string
Label string
Help string
}
// NotificationOptout field values (stored in user ProfileField table) // NotificationOptout field values (stored in user ProfileField table)
const ( const (
NotificationOptOutFriendPhotos = "notif_optout_friends_photos" NotificationOptOutFriendPhotos = "notif_optout_friends_photos"
@ -175,10 +127,6 @@ const (
NotificationOptOutSubscriptions = "notif_optout_subscriptions" NotificationOptOutSubscriptions = "notif_optout_subscriptions"
NotificationOptOutFriendRequestAccepted = "notif_optout_friend_request_accepted" NotificationOptOutFriendRequestAccepted = "notif_optout_friend_request_accepted"
NotificationOptOutPrivateGrant = "notif_optout_private_grant" NotificationOptOutPrivateGrant = "notif_optout_private_grant"
// Web Push Notifications
PushNotificationOptOutMessage = "notif_optout_push_messages"
PushNotificationOptOutFriends = "notif_optout_push_friends"
) )
// Notification opt-outs (stored in ProfileField table) // Notification opt-outs (stored in ProfileField table)
@ -192,9 +140,3 @@ var NotificationOptOutFields = []string{
NotificationOptOutFriendRequestAccepted, NotificationOptOutFriendRequestAccepted,
NotificationOptOutPrivateGrant, NotificationOptOutPrivateGrant,
} }
// Push Notification opt-outs (stored in ProfileField table)
var PushNotificationOptOutFields = []string{
PushNotificationOptOutMessage,
PushNotificationOptOutFriends,
}

View File

@ -15,16 +15,13 @@ var (
PageSizePrivatePhotoGrantees = 12 PageSizePrivatePhotoGrantees = 12
PageSizeAdminCertification = 20 PageSizeAdminCertification = 20
PageSizeAdminFeedback = 20 PageSizeAdminFeedback = 20
PageSizeAdminFeedbackNotesPage = 5 // feedback on User Notes page PageSizeAdminFeedbackNotesPage = 5 // feedback on User Notes page
PageSizeChangeLog = 20
PageSizeAdminUserNotes = 10 // other users' notes PageSizeAdminUserNotes = 10 // other users' notes
PageSizeSiteGallery = 16 PageSizeSiteGallery = 16
PageSizeUserGallery = 16 PageSizeUserGallery = 16
PageSizeInboxList = 20 // sidebar list PageSizeInboxList = 20 // sidebar list
PageSizeInboxThread = 10 // conversation view PageSizeInboxThread = 10 // conversation view
PageSizeBrowseForums = 20
PageSizeForums = 100 // TODO: for main category index view PageSizeForums = 100 // TODO: for main category index view
PageSizeMyListForums = 20 // "My List" pager on forum home (categories) page.
PageSizeThreadList = 20 // 20 threads per board, 20 posts per thread PageSizeThreadList = 20 // 20 threads per board, 20 posts per thread
PageSizeForumAdmin = 20 PageSizeForumAdmin = 20
PageSizeDashboardNotifications = 50 PageSizeDashboardNotifications = 50

View File

@ -4,18 +4,17 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil"
"os" "os"
"code.nonshy.com/nonshy/website/pkg/encryption/coldstorage"
"code.nonshy.com/nonshy/website/pkg/encryption/keygen" "code.nonshy.com/nonshy/website/pkg/encryption/keygen"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
"github.com/SherClockHolmes/webpush-go"
"github.com/google/uuid" "github.com/google/uuid"
) )
// Version of the config format - when new fields are added, it will attempt // Version of the config format - when new fields are added, it will attempt
// to write the settings.toml to disk so new defaults populate. // to write the settings.toml to disk so new defaults populate.
var currentVersion = 5 var currentVersion = 3
// Current loaded settings.json // Current loaded settings.json
var Current = DefaultVariable() var Current = DefaultVariable()
@ -32,9 +31,7 @@ type Variable struct {
BareRTC BareRTC BareRTC BareRTC
Maintenance Maintenance Maintenance Maintenance
Encryption Encryption Encryption Encryption
SignedPhoto SignedPhoto FaceScore FaceScore
WebPush WebPush
Turnstile Turnstile
UseXForwardedFor bool UseXForwardedFor bool
} }
@ -56,6 +53,9 @@ func DefaultVariable() Variable {
SQLite: "database.sqlite", SQLite: "database.sqlite",
Postgres: "host=localhost user=nonshy password=nonshy dbname=nonshy port=5679 sslmode=disable TimeZone=America/Los_Angeles", Postgres: "host=localhost user=nonshy password=nonshy dbname=nonshy port=5679 sslmode=disable TimeZone=America/Los_Angeles",
}, },
FaceScore: FaceScore{
CascadeFile: "/path/to/cascade/file",
},
CronAPIKey: uuid.New().String(), CronAPIKey: uuid.New().String(),
} }
} }
@ -66,7 +66,7 @@ func LoadSettings() {
if _, err := os.Stat(SettingsPath); !os.IsNotExist(err) { if _, err := os.Stat(SettingsPath); !os.IsNotExist(err) {
log.Info("Loading settings from %s", SettingsPath) log.Info("Loading settings from %s", SettingsPath)
content, err := os.ReadFile(SettingsPath) content, err := ioutil.ReadFile(SettingsPath)
if err != nil { if err != nil {
panic(fmt.Sprintf("LoadSettings: couldn't read settings.json: %s", err)) panic(fmt.Sprintf("LoadSettings: couldn't read settings.json: %s", err))
} }
@ -101,38 +101,6 @@ func LoadSettings() {
writeSettings = true writeSettings = true
} }
// Initialize the cold storage RSA keys.
if len(Current.Encryption.ColdStorageRSAPublicKey) == 0 {
x509publicKey, err := coldstorage.Initialize()
if err != nil {
log.Error("Initializing cold storage: %s", err)
os.Exit(1)
}
// Store the public key in the settings.json.
Current.Encryption.ColdStorageRSAPublicKey = x509publicKey
writeSettings = true
}
// Initialize the VAPID keys for Web Push Notification.
if len(Current.WebPush.VAPIDPublicKey) == 0 {
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
if err != nil {
log.Error("Initializing VAPID keys for Web Push: %s", err)
os.Exit(1)
}
Current.WebPush.VAPIDPrivateKey = privateKey
Current.WebPush.VAPIDPublicKey = publicKey
writeSettings = true
}
// Initialize JWT token for SignedPhoto feature.
if Current.SignedPhoto.JWTSecret == "" {
Current.SignedPhoto.JWTSecret = uuid.New().String()
writeSettings = true
}
// Have we added new config fields? Save the settings.json. // Have we added new config fields? Save the settings.json.
if Current.Version != currentVersion || writeSettings { if Current.Version != currentVersion || writeSettings {
log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.") log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.")
@ -155,7 +123,7 @@ func WriteSettings() error {
panic(fmt.Sprintf("WriteSettings: couldn't marshal settings: %s", err)) panic(fmt.Sprintf("WriteSettings: couldn't marshal settings: %s", err))
} }
return os.WriteFile(SettingsPath, buf.Bytes(), 0600) return ioutil.WriteFile(SettingsPath, buf.Bytes(), 0600)
} }
// Mail settings. // Mail settings.
@ -199,25 +167,11 @@ type Maintenance struct {
// Encryption settings. // Encryption settings.
type Encryption struct { type Encryption struct {
AESKey []byte AESKey []byte
ColdStorageRSAPublicKey []byte
} }
// SignedPhoto settings. // FaceScore settings for face detection in photos via esimov/pigo.
type SignedPhoto struct { type FaceScore struct {
Enabled bool Enabled bool
JWTSecret string CascadeFile string
}
// WebPush settings.
type WebPush struct {
VAPIDPublicKey string
VAPIDPrivateKey string
}
// Turnstile (Cloudflare CAPTCHA) settings.
type Turnstile struct {
Enabled bool
SiteKey string
SecretKey string
} }

View File

@ -43,17 +43,14 @@ func Dashboard() http.HandlerFunc {
return return
} }
// Parse notification filters.
nf := models.NewNotificationFilterFromForm(r)
// Get our notifications. // Get our notifications.
pager := &models.Pagination{ pager := &models.Pagination{
Page: 1, Page: 1,
PerPage: config.PageSizeDashboardNotifications, PerPage: config.PageSizeDashboardNotifications,
Sort: "read, created_at desc", Sort: "created_at desc",
} }
pager.ParsePage(r) pager.ParsePage(r)
notifs, err := models.PaginateNotifications(currentUser, nf, pager) notifs, err := models.PaginateNotifications(currentUser, pager)
if err != nil { if err != nil {
session.FlashError(w, r, "Couldn't get your notifications: %s", err) session.FlashError(w, r, "Couldn't get your notifications: %s", err)
} }
@ -89,7 +86,6 @@ func Dashboard() http.HandlerFunc {
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"Notifications": notifs, "Notifications": notifs,
"NotifMap": notifMap, "NotifMap": notifMap,
"Filters": nf,
"Pager": pager, "Pager": pager,
// Show a warning to 'restricted' profiles who are especially private. // Show a warning to 'restricted' profiles who are especially private.

View File

@ -4,8 +4,6 @@ import (
"net/http" "net/http"
"strings" "strings"
"code.nonshy.com/nonshy/website/pkg/chat"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
@ -43,14 +41,6 @@ func Deactivate() http.HandlerFunc {
session.LogoutUser(w, r) session.LogoutUser(w, r)
session.Flash(w, r, "Your account has been deactivated and you are now logged out. If you wish to re-activate your account, sign in again with your username and password.") session.Flash(w, r, "Your account has been deactivated and you are now logged out. If you wish to re-activate your account, sign in again with your username and password.")
templates.Redirect(w, "/") templates.Redirect(w, "/")
// 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)
}
// Log the change.
models.LogEvent(currentUser, nil, models.ChangeLogLifecycle, "users", currentUser.ID, "Deactivated their account.")
return return
} }
@ -88,8 +78,5 @@ func Reactivate() http.HandlerFunc {
session.Flash(w, r, "Welcome back! Your account has been reactivated.") session.Flash(w, r, "Welcome back! Your account has been reactivated.")
templates.Redirect(w, "/") templates.Redirect(w, "/")
// Log the change.
models.LogEvent(currentUser, nil, models.ChangeLogLifecycle, "users", currentUser.ID, "Reactivated their account.")
}) })
} }

View File

@ -1,13 +1,9 @@
package account package account
import ( import (
"fmt"
"net/http" "net/http"
"strings" "strings"
"code.nonshy.com/nonshy/website/pkg/chat"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/models/deletion" "code.nonshy.com/nonshy/website/pkg/models/deletion"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
@ -44,14 +40,6 @@ func Delete() http.HandlerFunc {
session.LogoutUser(w, r) session.LogoutUser(w, r)
session.Flash(w, r, "Your account has been deleted.") session.Flash(w, r, "Your account has been deleted.")
templates.Redirect(w, "/") templates.Redirect(w, "/")
// Kick them from the chat room if they are online.
if _, err := chat.DisconnectUserNow(currentUser, "You have been signed out of chat because you had deleted your account."); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
}
// Log the change.
models.LogDeleted(nil, nil, "users", currentUser.ID, fmt.Sprintf("Username %s has deleted their account.", currentUser.Username), nil)
return return
} }

View File

@ -2,6 +2,7 @@ package account
import ( import (
"net/http" "net/http"
"regexp"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
@ -9,12 +10,18 @@ import (
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
) )
var UserFriendsRegexp = regexp.MustCompile(`^/friends/u/([^@]+?)$`)
// User friends page (/friends/u/username) // User friends page (/friends/u/username)
func UserFriends() http.HandlerFunc { func UserFriends() http.HandlerFunc {
tmpl := templates.Must("account/friends.html") tmpl := templates.Must("account/friends.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the username out of the URL parameters. // Parse the username out of the URL parameters.
var username = r.PathValue("username") var username string
m := UserFriendsRegexp.FindStringSubmatch(r.URL.Path)
if m != nil {
username = m[1]
}
// Find this user. // Find this user.
user, err := models.FindUser(username) user, err := models.FindUser(username)

View File

@ -0,0 +1,168 @@
package account
import (
"fmt"
"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"
)
// InnerCircle is the landing page for inner circle members only.
func InnerCircle() http.HandlerFunc {
tmpl := templates.Must("account/inner_circle.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, err := session.CurrentUser(r)
if err != nil || !currentUser.IsInnerCircle() {
templates.NotFoundPage(w, r)
return
}
var vars = map[string]interface{}{
"InnerCircleMinimumPublicPhotos": config.InnerCircleMinimumPublicPhotos,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
// InviteCircle is the landing page to invite a user into the circle.
func InviteCircle() http.HandlerFunc {
tmpl := templates.Must("account/invite_circle.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, err := session.CurrentUser(r)
if err != nil || !currentUser.IsInnerCircle() {
templates.NotFoundPage(w, r)
return
}
// Invite whom?
username := r.FormValue("to")
user, err := models.FindUser(username)
if err != nil {
templates.NotFoundPage(w, r)
return
}
if currentUser.ID == user.ID && currentUser.InnerCircle {
session.FlashError(w, r, "You are already part of the inner circle.")
templates.Redirect(w, "/inner-circle")
return
}
// Any blocking?
if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
session.FlashError(w, r, "You are blocked from inviting this user to the circle.")
templates.Redirect(w, "/inner-circle")
return
}
// POSTing?
if r.Method == http.MethodPost {
var (
confirm = r.FormValue("intent") == "confirm"
)
if !confirm {
templates.Redirect(w, "/u/"+username)
return
}
// Add them!
if err := models.AddToInnerCircle(user); err != nil {
session.FlashError(w, r, "Couldn't add to the inner circle: %s", err)
} else {
session.Flash(w, r, "%s has been added to the inner circle!", user.Username)
}
log.Info("InnerCircle: %s adds %s to the inner circle", currentUser.Username, user.Username)
templates.Redirect(w, "/photo/u/"+user.Username)
return
}
var vars = map[string]interface{}{
"User": user,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
// RemoveCircle is the admin-only page to remove a member from the circle.
func RemoveCircle() http.HandlerFunc {
tmpl := templates.Must("account/remove_circle.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, err := session.CurrentUser(r)
if err != nil || !currentUser.IsInnerCircle() {
templates.NotFoundPage(w, r)
return
}
// Remove whom?
username := r.FormValue("to")
user, err := models.FindUser(username)
if err != nil {
templates.NotFoundPage(w, r)
return
}
// POSTing?
if r.Method == http.MethodPost {
var (
confirm = r.FormValue("intent") == "confirm"
)
if !confirm {
templates.Redirect(w, "/u/"+username)
return
}
// Admin (with the correct scope): remove them now.
if currentUser.HasAdminScope(config.ScopeCircleModerator) {
if err := models.RemoveFromInnerCircle(user); err != nil {
session.FlashError(w, r, "Couldn't remove from the inner circle: %s", err)
}
session.Flash(w, r, "%s has been removed from the inner circle!", user.Username)
} else {
// Non-admin user: request removal only.
fb := &models.Feedback{
Intent: "report.circle",
Subject: "Inner Circle Removal Request",
TableName: "users",
TableID: user.ID,
Message: fmt.Sprintf(
"An inner circle member has flagged that **%s** no longer qualifies to be a part of the inner circle and should be removed.",
user.Username,
),
}
if err := models.CreateFeedback(fb); err != nil {
session.FlashError(w, r, "Couldn't create admin notification: %s", err)
} else {
session.Flash(w, r, "A request to remove %s from the inner circle has been sent to the site admin.", user.Username)
}
}
templates.Redirect(w, "/u/"+user.Username)
return
}
var vars = map[string]interface{}{
"User": user,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -38,23 +38,10 @@ func Login() http.HandlerFunc {
CooldownAt: config.LoginRateLimitCooldownAt, CooldownAt: config.LoginRateLimitCooldownAt,
Cooldown: config.LoginRateLimitCooldown, Cooldown: config.LoginRateLimitCooldown,
} }
var takebackDeferredError bool
if err := limiter.Ping(); err != nil { if err := limiter.Ping(); err != nil {
// Is it a deferred error? Flash it at the end of the request but continue session.FlashError(w, r, err.Error())
// to process this login attempt as normal. templates.Redirect(w, r.URL.Path)
if ratelimit.IsDeferredError(err) { return
defer func() {
if takebackDeferredError {
return
}
session.FlashError(w, r, err.Error())
}()
} else {
// Lock-out error, show it now and quit.
session.FlashError(w, r, err.Error())
templates.Redirect(w, r.URL.Path)
return
}
} }
// Look up their account. // Look up their account.
@ -137,9 +124,6 @@ func Login() http.HandlerFunc {
log.Error("Failed to clear login rate limiter: %s", err) log.Error("Failed to clear login rate limiter: %s", err)
} }
// If there was going to be a deferred ratelimit error, take it back.
takebackDeferredError = true
// Redirect to their dashboard. // Redirect to their dashboard.
session.Flash(w, r, "Login successful.") session.Flash(w, r, "Login successful.")
if strings.HasPrefix(next, "/") { if strings.HasPrefix(next, "/") {

View File

@ -3,9 +3,8 @@ package account
import ( import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "regexp"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/middleware" "code.nonshy.com/nonshy/website/pkg/middleware"
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
@ -14,12 +13,18 @@ import (
"code.nonshy.com/nonshy/website/pkg/worker" "code.nonshy.com/nonshy/website/pkg/worker"
) )
var ProfileRegexp = regexp.MustCompile(`^/u/([^@]+?)$`)
// User profile page (/u/username) // User profile page (/u/username)
func Profile() http.HandlerFunc { func Profile() http.HandlerFunc {
tmpl := templates.Must("account/profile.html") tmpl := templates.Must("account/profile.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the username out of the URL parameters. // Parse the username out of the URL parameters.
var username = r.PathValue("username") var username string
m := ProfileRegexp.FindStringSubmatch(r.URL.Path)
if m != nil {
username = m[1]
}
// Find this user. // Find this user.
user, err := models.FindUser(username) user, err := models.FindUser(username)
@ -40,8 +45,7 @@ func Profile() http.HandlerFunc {
} }
vars := map[string]interface{}{ vars := map[string]interface{}{
"User": user, "User": user,
"IsExternalView": true,
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
@ -73,18 +77,23 @@ func Profile() http.HandlerFunc {
// Inject relationship booleans for profile picture display. // Inject relationship booleans for profile picture display.
models.SetUserRelationships(currentUser, []*models.User{user}) models.SetUserRelationships(currentUser, []*models.User{user})
// Admin user (photo moderator) can always see the profile pic - but only on this page. // Admin user can always see the profile pic - but only on this page. Other avatar displays
// Other avatar displays will show the yellow or pink shy.png if the admin is not friends or not granted. // will show the yellow or pink shy.png if the admin is not friends or not granted.
if currentUser.HasAdminScope(config.ScopePhotoModerator) { if currentUser.IsAdmin {
user.UserRelationship.IsFriend = true user.UserRelationship.IsFriend = true
user.UserRelationship.IsPrivateGranted = true user.UserRelationship.IsPrivateGranted = true
} }
var isSelf = currentUser.ID == user.ID var isSelf = currentUser.ID == user.ID
// Give a Not Found page if we can not see this user. // Banned or disabled? Only admin can view then.
if err := user.CanBeSeenBy(currentUser); err != nil { if user.Status != models.UserStatusActive && !currentUser.IsAdmin {
log.Error("%s can not be seen by viewer %s: %s", user.Username, currentUser.Username, err) templates.NotFoundPage(w, r)
return
}
// Is either one blocking?
if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
templates.NotFoundPage(w, r) templates.NotFoundPage(w, r)
return return
} }
@ -104,32 +113,27 @@ func Profile() http.HandlerFunc {
log.Error("WhoLikes(user %d): %s", user.ID, err) log.Error("WhoLikes(user %d): %s", user.ID, err)
} }
// Chat Moderation Rule: count of rules applied to the user, for admin view.
var chatModerationRules int
if currentUser.HasAdminScope(config.ScopeChatModerator) {
if rules := user.GetProfileField("chat_moderation_rules"); len(rules) > 0 {
chatModerationRules = len(strings.Split(rules, ","))
}
}
vars := map[string]interface{}{ vars := map[string]interface{}{
"User": user, "User": user,
"LikeMap": likeMap, "LikeMap": likeMap,
"IsFriend": isFriend, "IsFriend": isFriend,
"IsPrivate": isPrivate, "IsPrivate": isPrivate,
"PhotoCount": models.CountPhotosICanSee(user, currentUser), "PhotoCount": models.CountPhotosICanSee(user, currentUser),
"NoteCount": models.CountNotesAboutUser(currentUser, user), "NoteCount": models.CountNotesAboutUser(currentUser, user),
"FriendCount": models.CountFriends(user.ID), "FriendCount": models.CountFriends(user.ID),
"OnChat": worker.GetChatStatistics().IsOnline(user.Username), "ForumThreadCount": models.CountThreadsByUser(user),
"ForumReplyCount": models.CountCommentsByUser(user, "threads"),
"PhotoCommentCount": models.CountCommentsByUser(user, "photos"),
"CommentsReceivedCount": models.CountCommentsReceived(user),
"LikesGivenCount": models.CountLikesGiven(user),
"LikesReceivedCount": models.CountLikesReceived(user),
"OnChat": worker.GetChatStatistics().IsOnline(user.Username),
// Details on who likes their profile page. // Details on who likes their profile page.
"LikeExample": likeExample, "LikeExample": likeExample,
"LikeRemainder": likeRemainder, "LikeRemainder": likeRemainder,
"LikeTableName": "users", "LikeTableName": "users",
"LikeTableID": user.ID, "LikeTableID": user.ID,
// Admin numbers.
"NumChatModerationRules": chatModerationRules,
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {

View File

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

View File

@ -1,17 +1,14 @@
package account package account
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/controller/chat"
"code.nonshy.com/nonshy/website/pkg/geoip" "code.nonshy.com/nonshy/website/pkg/geoip"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/spam"
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
"code.nonshy.com/nonshy/website/pkg/worker" "code.nonshy.com/nonshy/website/pkg/worker"
) )
@ -24,7 +21,6 @@ func Search() http.HandlerFunc {
var sortWhitelist = []string{ var sortWhitelist = []string{
"last_login_at desc", "last_login_at desc",
"created_at desc", "created_at desc",
"certified_at desc",
"username", "username",
"username desc", "username desc",
"lower(name)", "lower(name)",
@ -36,16 +32,12 @@ func Search() http.HandlerFunc {
// Search filters. // Search filters.
var ( var (
isCertified = r.FormValue("certified") isCertified = r.FormValue("certified")
username = r.FormValue("name") // username search username = r.FormValue("username") // username search
searchTerm = r.FormValue("search") // profile text search
citySearch = r.FormValue("wcs")
gender = r.FormValue("gender") gender = r.FormValue("gender")
orientation = r.FormValue("orientation") orientation = r.FormValue("orientation")
maritalStatus = r.FormValue("marital_status") maritalStatus = r.FormValue("marital_status")
hereFor = r.FormValue("here_for") hereFor = r.FormValue("here_for")
friendSearch = r.FormValue("friends") == "true" friendSearch = r.FormValue("friends") == "true"
likedSearch = r.FormValue("liked") == "true"
onChatSearch = r.FormValue("on_chat") == "true"
sort = r.FormValue("sort") sort = r.FormValue("sort")
sortOK bool sortOK bool
) )
@ -56,9 +48,6 @@ func Search() http.HandlerFunc {
ageMin, ageMax = ageMax, ageMin ageMin, ageMax = ageMax, ageMin
} }
rawSearch := models.ParseSearchString(searchTerm)
search, restricted := spam.RestrictSearchTerms(rawSearch)
// Get current user. // Get current user.
currentUser, err := session.CurrentUser(r) currentUser, err := session.CurrentUser(r)
if err != nil { if err != nil {
@ -67,32 +56,6 @@ func Search() http.HandlerFunc {
return return
} }
// Report when search terms are restricted.
if restricted != nil {
// Admin users: allow the search anyway.
if currentUser.IsAdmin {
search = rawSearch
} else {
fb := &models.Feedback{
Intent: "report",
Subject: "Search Keyword Blacklist",
UserID: currentUser.ID,
TableName: "users",
TableID: currentUser.ID,
Message: fmt.Sprintf(
"A user has run a search on the Member Directory using search terms which are prohibited.\n\n"+
"Their search query was: %s",
searchTerm,
),
}
// Save the feedback.
if err := models.CreateFeedback(fb); err != nil {
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
}
}
}
// Geolocation/Who's Nearby: if the current user uses GeoIP, update // Geolocation/Who's Nearby: if the current user uses GeoIP, update
// their coordinates now. // their coordinates now.
myLocation, err := models.RefreshGeoIP(currentUser.ID, r) myLocation, err := models.RefreshGeoIP(currentUser.ID, r)
@ -100,24 +63,6 @@ func Search() http.HandlerFunc {
log.Error("RefreshGeoIP: %s", err) log.Error("RefreshGeoIP: %s", err)
} }
// Are they doing a Location search (from world city typeahead)?
var city *models.WorldCities
if citySearch != "" {
sort = "distance"
// Require the current user to have THEIR location set, for fairness.
if myLocation.Source == models.LocationSourceNone {
session.FlashError(w, r, "You must set your own location before you can search for others by their location.")
} else {
// Look up the coordinates of their search.
city, err = models.FindWorldCity(citySearch)
if err != nil {
session.FlashError(w, r, "Location search: no match was found for '%s', please use one of the exact search results from the type-ahead on the Location field.", citySearch)
citySearch = "" // null out their search
}
}
}
// Sort options. // Sort options.
for _, v := range sortWhitelist { for _, v := range sortWhitelist {
if sort == v { if sort == v {
@ -129,39 +74,11 @@ func Search() http.HandlerFunc {
sort = "last_login_at desc" sort = "last_login_at desc"
} }
// Real name for certified_at
if sort == "certified_at desc" {
sort = "certification_photos.updated_at desc"
}
// Default // Default
if isCertified == "" { if isCertified == "" {
isCertified = "true" isCertified = "true"
} }
// Always filter for certified-only users unless the request specifically looked for non-certified.
// Searches for disabled/banned users (admin only) should also reveal ALL users including non-certified.
var certifiedOnly = true
if isCertified == "false" || isCertified == "all" || isCertified == "disabled" || isCertified == "banned" {
certifiedOnly = false
}
// Non-admin view: always hide non-certified profiles, they can be unsafe (fake profiles, scams if they won't certify)
if !currentUser.IsAdmin {
certifiedOnly = true
}
// Are we filtering for "On Chat?"
var inUsername = []string{}
if onChatSearch {
stats := chat.FilteredChatStatistics(currentUser)
inUsername = stats.Usernames
if len(inUsername) == 0 {
session.FlashError(w, r, "Notice: you wanted to filter by people currently on the chat room, but nobody is on chat at this time.")
inUsername = []string{"@"}
}
}
pager := &models.Pagination{ pager := &models.Pagination{
PerPage: config.PageSizeMemberSearch, PerPage: config.PageSizeMemberSearch,
Sort: sort, Sort: sort,
@ -170,26 +87,21 @@ func Search() http.HandlerFunc {
users, err := models.SearchUsers(currentUser, &models.UserSearch{ users, err := models.SearchUsers(currentUser, &models.UserSearch{
Username: username, Username: username,
InUsername: inUsername,
Gender: gender, Gender: gender,
Orientation: orientation, Orientation: orientation,
MaritalStatus: maritalStatus, MaritalStatus: maritalStatus,
HereFor: hereFor, HereFor: hereFor,
ProfileText: search, Certified: isCertified == "true",
Certified: certifiedOnly,
NearCity: city,
NotCertified: isCertified == "false", NotCertified: isCertified == "false",
InnerCircle: isCertified == "circle",
ShyAccounts: isCertified == "shy", ShyAccounts: isCertified == "shy",
IsBanned: isCertified == "banned", IsBanned: isCertified == "banned",
IsDisabled: isCertified == "disabled",
IsAdmin: isCertified == "admin",
Friends: friendSearch, Friends: friendSearch,
Liked: likedSearch,
AgeMin: ageMin, AgeMin: ageMin,
AgeMax: ageMax, AgeMax: ageMax,
}, pager) }, pager)
if err != nil { if err != nil {
session.FlashError(w, r, "An error has occurred: %s.", err) session.FlashError(w, r, "Couldn't search users: %s", err)
} }
// Who's Nearby feature, get some data. // Who's Nearby feature, get some data.
@ -197,16 +109,8 @@ func Search() http.HandlerFunc {
// Collect usernames to map to chat online status. // Collect usernames to map to chat online status.
var usernames = []string{} var usernames = []string{}
var userIDs = []uint64{}
for _, user := range users { for _, user := range users {
usernames = append(usernames, user.Username) usernames = append(usernames, user.Username)
userIDs = append(userIDs, user.ID)
}
// User IDs of these I have "Liked"
likedIDs, err := models.LikedIDs(currentUser, "users", userIDs)
if err != nil {
log.Error("LikedIDs: %s", err)
} }
var vars = map[string]interface{}{ var vars = map[string]interface{}{
@ -221,27 +125,19 @@ func Search() http.HandlerFunc {
"MaritalStatus": maritalStatus, "MaritalStatus": maritalStatus,
"HereFor": hereFor, "HereFor": hereFor,
"EmailOrUsername": username, "EmailOrUsername": username,
"Search": searchTerm,
"City": citySearch,
"AgeMin": ageMin, "AgeMin": ageMin,
"AgeMax": ageMax, "AgeMax": ageMax,
"FriendSearch": friendSearch, "FriendSearch": friendSearch,
"LikedSearch": likedSearch,
"OnChatSearch": onChatSearch,
"Sort": sort, "Sort": sort,
// Restricted Search errors.
"RestrictedSearchError": restricted,
// Photo counts mapped to users // Photo counts mapped to users
"PhotoCountMap": models.MapPhotoCounts(users), "PhotoCountMap": models.MapPhotoCounts(users),
// Map Shy Account badges for these results // Map Shy Account badges for these results
"ShyMap": models.MapShyAccounts(users), "ShyMap": models.MapShyAccounts(users),
// Map friendships and likes to these users. // Map friendships to these users.
"FriendMap": models.MapFriends(currentUser, users), "FriendMap": models.MapFriends(currentUser, users),
"LikedMap": models.MapLikes(currentUser, "users", likedIDs),
// Users on the chat room map. // Users on the chat room map.
"UserOnChatMap": worker.GetChatStatistics().MapUsersOnline(usernames), "UserOnChatMap": worker.GetChatStatistics().MapUsersOnline(usernames),
@ -249,7 +145,7 @@ func Search() http.HandlerFunc {
// Current user's location setting. // Current user's location setting.
"MyLocation": myLocation, "MyLocation": myLocation,
"GeoIPInsights": insights, "GeoIPInsights": insights,
"DistanceMap": models.MapDistances(currentUser, city, users), "DistanceMap": models.MapDistances(currentUser, users),
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {

View File

@ -9,7 +9,6 @@ import (
"strings" "strings"
"time" "time"
"code.nonshy.com/nonshy/website/pkg/chat"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/geoip" "code.nonshy.com/nonshy/website/pkg/geoip"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
@ -17,10 +16,8 @@ import (
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/redis" "code.nonshy.com/nonshy/website/pkg/redis"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/spam"
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
"code.nonshy.com/nonshy/website/pkg/utility" "code.nonshy.com/nonshy/website/pkg/utility"
"code.nonshy.com/nonshy/website/pkg/worker"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -53,19 +50,11 @@ func Settings() http.HandlerFunc {
return return
} }
// Is the user currently in the chat room? Gate username changes when so.
var isOnChat = worker.GetChatStatistics().IsOnline(user.Username)
vars["OnChat"] = isOnChat
// URL hashtag to redirect to // URL hashtag to redirect to
var hashtag string var hashtag string
// Are we POSTing? // Are we POSTing?
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
// Will they BECOME a Shy Account with this change?
var wasShy = user.IsShy()
intent := r.PostFormValue("intent") intent := r.PostFormValue("intent")
switch intent { switch intent {
case "profile": case "profile":
@ -119,15 +108,7 @@ func Settings() http.HandlerFunc {
// Set profile attributes. // Set profile attributes.
for _, attr := range config.ProfileFields { for _, attr := range config.ProfileFields {
var value = strings.TrimSpace(r.PostFormValue(attr)) user.SetProfileField(attr, r.PostFormValue(attr))
// Look for spammy links to restricted video sites or things.
if err := spam.DetectSpamMessage(value); err != nil {
session.FlashError(w, r, "On field '%s': %s", attr, err.Error())
continue
}
user.SetProfileField(attr, value)
} }
// "Looking For" checkbox list. // "Looking For" checkbox list.
@ -186,7 +167,6 @@ func Settings() http.HandlerFunc {
for _, field := range []string{ for _, field := range []string{
"hero-text-dark", "hero-text-dark",
"card-lightness", "card-lightness",
"website-theme",
} { } {
value := r.PostFormValue(field) value := r.PostFormValue(field)
user.SetProfileField(field, value) user.SetProfileField(field, value)
@ -224,7 +204,6 @@ func Settings() http.HandlerFunc {
var ( var (
visibility = models.UserVisibility(r.PostFormValue("visibility")) visibility = models.UserVisibility(r.PostFormValue("visibility"))
dmPrivacy = r.PostFormValue("dm_privacy") dmPrivacy = r.PostFormValue("dm_privacy")
ppPrivacy = r.PostFormValue("private_photo_gate")
) )
user.Visibility = models.UserVisibilityPublic user.Visibility = models.UserVisibilityPublic
@ -237,7 +216,6 @@ func Settings() http.HandlerFunc {
// Set profile field prefs. // Set profile field prefs.
user.SetProfileField("dm_privacy", dmPrivacy) user.SetProfileField("dm_privacy", dmPrivacy)
user.SetProfileField("private_photo_gate", ppPrivacy)
if err := user.Save(); err != nil { if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err) session.FlashError(w, r, "Failed to save user to database: %s", err)
@ -282,28 +260,6 @@ func Settings() http.HandlerFunc {
session.Flash(w, r, "Unsubscribed from all comment threads!") session.Flash(w, r, "Unsubscribed from all comment threads!")
} }
} }
case "push_notifications":
hashtag = "#notifications"
// Store their notification opt-outs.
for _, key := range config.PushNotificationOptOutFields {
var value = r.PostFormValue(key)
if value == "" {
value = "true" // opt-out, store opt-out=true in the DB
} else if value == "true" {
value = "false" // the box remained checked, they don't opt-out, store opt-out=false in the DB
}
// Save it.
user.SetProfileField(key, value)
}
session.Flash(w, r, "Notification preferences updated!")
// Save the user for new fields to be committed to DB.
if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err)
}
case "location": case "location":
hashtag = "#location" hashtag = "#location"
var ( var (
@ -337,91 +293,32 @@ func Settings() http.HandlerFunc {
case "settings": case "settings":
hashtag = "#account" hashtag = "#account"
var ( var (
oldPassword = r.PostFormValue("old_password") oldPassword = r.PostFormValue("old_password")
changeEmail = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_email"))) changeEmail = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_email")))
changeUsername = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_username"))) password1 = strings.TrimSpace(r.PostFormValue("new_password"))
password1 = strings.TrimSpace(r.PostFormValue("new_password")) password2 = strings.TrimSpace(r.PostFormValue("new_password2"))
password2 = strings.TrimSpace(r.PostFormValue("new_password2"))
) )
// Their old password is needed to make any changes to their account. // Their old password is needed to make any changes to their account.
if err := user.CheckPassword(oldPassword); err != nil { if err := user.CheckPassword(oldPassword); err != nil {
session.FlashError(w, r, "Could not make changes to your account settings as the 'current password' you entered was incorrect.") session.FlashError(w, r, "Could not make changes to your account settings as the 'current password' you entered was incorrect.")
templates.Redirect(w, r.URL.Path+hashtag) templates.Redirect(w, r.URL.Path)
return return
} }
// Changing their username?
if changeUsername != user.Username {
// Not if they are in the chat room!
if isOnChat {
session.FlashError(w, r, "Your username could not be changed right now because you are logged into the chat room. Please exit the chat room, wait a minute, and try your request again.")
templates.Redirect(w, r.URL.Path+hashtag)
return
}
// Check if the new name is OK.
if err := models.IsValidUsername(changeUsername); err != nil {
session.FlashError(w, r, "Could not change your username: %s", err.Error())
templates.Redirect(w, r.URL.Path+hashtag)
return
}
// Clear their history on the chat room.
go func(username string) {
log.Error("Change of username, clear chat history for old name %s", username)
i, err := chat.EraseChatHistory(username)
if err != nil {
log.Error("EraseChatHistory(%s): %s", username, err)
return
}
session.Flash(w, r, "Notice: due to your recent change in username, your direct message history on the Chat Room has been reset. %d message(s) had been removed.", i)
}(user.Username)
// Set their name.
origUsername := user.Username
user.Username = changeUsername
if err := user.Save(); err != nil {
session.FlashError(w, r, "Error saving your new username: %s", err)
} else {
session.Flash(w, r, "Your username has been updated to: %s", user.Username)
// Notify the admin about this to keep tabs if someone is acting strangely
// with too-frequent username changes.
fb := &models.Feedback{
Intent: "report",
Subject: "Change of username",
UserID: user.ID,
TableName: "users",
TableID: user.ID,
Message: fmt.Sprintf(
"A user has modified their username on their profile page!\n\n"+
"* Original: %s\n* Updated: %s",
origUsername, changeUsername,
),
}
// Save the feedback.
if err := models.CreateFeedback(fb); err != nil {
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
}
}
}
// Changing their email? // Changing their email?
if changeEmail != user.Email { if changeEmail != user.Email {
// Validate the email. // Validate the email.
if _, err := nm.ParseAddress(changeEmail); err != nil { if _, err := nm.ParseAddress(changeEmail); err != nil {
session.FlashError(w, r, "The email address you entered is not valid: %s", err) session.FlashError(w, r, "The email address you entered is not valid: %s", err)
templates.Redirect(w, r.URL.Path+hashtag) templates.Redirect(w, r.URL.Path)
return return
} }
// Email must not already exist. // Email must not already exist.
if _, err := models.FindUser(changeEmail); err == nil { if _, err := models.FindUser(changeEmail); err == nil {
session.FlashError(w, r, "That email address is already in use.") session.FlashError(w, r, "That email address is already in use.")
templates.Redirect(w, r.URL.Path+hashtag) templates.Redirect(w, r.URL.Path)
return return
} }
@ -433,7 +330,7 @@ func Settings() http.HandlerFunc {
} }
if err := redis.Set(fmt.Sprintf(config.ChangeEmailRedisKey, token.Token), token, config.SignupTokenExpires); err != nil { if err := redis.Set(fmt.Sprintf(config.ChangeEmailRedisKey, token.Token), token, config.SignupTokenExpires); err != nil {
session.FlashError(w, r, "Failed to create change email token: %s", err) session.FlashError(w, r, "Failed to create change email token: %s", err)
templates.Redirect(w, r.URL.Path+hashtag) templates.Redirect(w, r.URL.Path)
return return
} }
@ -477,13 +374,6 @@ func Settings() http.HandlerFunc {
session.FlashError(w, r, "Unknown POST intent value. Please try again.") session.FlashError(w, r, "Unknown POST intent value. Please try again.")
} }
// 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)
}
}
templates.Redirect(w, r.URL.Path+hashtag+".") templates.Redirect(w, r.URL.Path+hashtag+".")
return return
} }
@ -502,9 +392,6 @@ func Settings() http.HandlerFunc {
// Count of subscribed comment threads. // Count of subscribed comment threads.
vars["SubscriptionCount"] = models.CountSubscriptions(user) vars["SubscriptionCount"] = models.CountSubscriptions(user)
// Count of push notification subscriptions.
vars["PushNotificationsCount"] = models.CountPushNotificationSubscriptions(user)
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return

View File

@ -14,7 +14,6 @@ import (
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/redis" "code.nonshy.com/nonshy/website/pkg/redis"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/spam"
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
"code.nonshy.com/nonshy/website/pkg/utility" "code.nonshy.com/nonshy/website/pkg/utility"
"github.com/google/uuid" "github.com/google/uuid"
@ -61,6 +60,7 @@ func Signup() http.HandlerFunc {
} }
var token SignupToken var token SignupToken
log.Info("SignupToken: %s", tokenStr)
if tokenStr != "" { if tokenStr != "" {
// Validate it. // Validate it.
if err := redis.Get(fmt.Sprintf(config.SignupTokenRedisKey, tokenStr), &token); err != nil || token.Token != tokenStr { if err := redis.Get(fmt.Sprintf(config.SignupTokenRedisKey, tokenStr), &token); err != nil || token.Token != tokenStr {
@ -72,6 +72,7 @@ func Signup() http.HandlerFunc {
vars["SignupToken"] = tokenStr vars["SignupToken"] = tokenStr
vars["Email"] = token.Email vars["Email"] = token.Email
} }
log.Info("Vars: %+v", vars)
// Posting? // Posting?
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
@ -85,34 +86,8 @@ func Signup() http.HandlerFunc {
password = strings.TrimSpace(r.PostFormValue("password")) password = strings.TrimSpace(r.PostFormValue("password"))
password2 = strings.TrimSpace(r.PostFormValue("password2")) password2 = strings.TrimSpace(r.PostFormValue("password2"))
dob = r.PostFormValue("dob") dob = r.PostFormValue("dob")
// CAPTCHA response.
turnstileCAPTCHA = r.PostFormValue("cf-turnstile-response")
// Honeytrap fields for lazy spam bots.
honeytrap1 = r.PostFormValue("phone") == ""
honeytrap2 = r.PostFormValue("referral") == "Word of mouth"
// Validation errors but still show the form again.
hasError bool
) )
// Honeytrap fields check.
if !honeytrap1 || !honeytrap2 {
session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email)
templates.Redirect(w, r.URL.Path)
return
}
// Validate the CAPTCHA token.
if config.Current.Turnstile.Enabled {
if err := spam.ValidateTurnstileCAPTCHA(turnstileCAPTCHA, "signup"); err != nil {
session.FlashError(w, r, "There was an error validating your CAPTCHA response.")
templates.Redirect(w, r.URL.Path)
return
}
}
// Don't let them sneakily change their verified email address on us. // Don't let them sneakily change their verified email address on us.
if vars["SignupToken"] != "" && email != vars["Email"] { if vars["SignupToken"] != "" && email != vars["Email"] {
session.FlashError(w, r, "This email address is not verified. Please start over from the beginning.") session.FlashError(w, r, "This email address is not verified. Please start over from the beginning.")
@ -120,10 +95,27 @@ func Signup() http.HandlerFunc {
return return
} }
// Reserved username check.
for _, cmp := range config.ReservedUsernames {
if username == cmp {
session.FlashError(w, r, "That username is reserved, please choose a different username.")
templates.Redirect(w, r.URL.Path+"?token="+tokenStr)
return
}
}
// Cache username in case of passwd validation errors. // Cache username in case of passwd validation errors.
vars["Email"] = email vars["Email"] = email
vars["Username"] = username vars["Username"] = username
// Is the app not configured to send email?
if !config.Current.Mail.Enabled {
session.FlashError(w, r, "This app is not configured to send email so you can not sign up at this time. "+
"Please contact the website administrator about this issue!")
templates.Redirect(w, r.URL.Path)
return
}
// Validate the email. // Validate the email.
if _, err := nm.ParseAddress(email); err != nil { if _, err := nm.ParseAddress(email); err != nil {
session.FlashError(w, r, "The email address you entered is not valid: %s", err) session.FlashError(w, r, "The email address you entered is not valid: %s", err)
@ -139,29 +131,20 @@ func Signup() http.HandlerFunc {
} }
// Already an account? // Already an account?
if user, err := models.FindUser(email); err == nil { if _, err := models.FindUser(email); err == nil {
// We don't want to admit that the email already is registered, so send an email to the // We don't want to admit that the email already is registered, so send an email to the
// address in case the user legitimately forgot, but flash the regular success message. // address in case the user legitimately forgot, but flash the regular success message.
if user.IsBanned() { err := mail.Send(mail.Message{
log.Error("Do not send signup e-mail to %s: user is banned", email) To: email,
} else { Subject: "You already have a nonshy account",
if err := mail.LockSending("signup", email, config.EmailDebounceDefault); err == nil { Template: "email/already_signed_up.html",
err := mail.Send(mail.Message{ Data: map[string]interface{}{
To: email, "Title": config.Title,
Subject: "You already have a nonshy account", "URL": config.Current.BaseURL + "/forgot-password",
Template: "email/already_signed_up.html", },
Data: map[string]interface{}{ })
"Title": config.Title, if err != nil {
"URL": config.Current.BaseURL + "/forgot-password", session.FlashError(w, r, "Error sending an email: %s", err)
},
})
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) 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)
@ -180,38 +163,20 @@ func Signup() http.HandlerFunc {
session.FlashError(w, r, "Error creating a link to send you: %s", err) session.FlashError(w, r, "Error creating a link to send you: %s", err)
} }
// Is the app not configured to send email? err := mail.Send(mail.Message{
if !config.Current.Mail.Enabled && !config.SkipEmailVerification { To: email,
// Log the signup token for local dev. Subject: "Verify your e-mail address",
log.Error("Signup: the app is not configured to send email. To continue, visit the URL: /signup?token=%s", token.Token) Template: "email/verify_email.html",
session.FlashError(w, r, "This app is not configured to send email so you can not sign up at this time. "+ Data: map[string]interface{}{
"Please contact the website administrator about this issue!") "Title": config.Title,
templates.Redirect(w, r.URL.Path) "URL": config.Current.BaseURL + "/signup?token=" + token.Token,
return },
} })
if err != nil {
if err := mail.LockSending("signup", email, config.SignupTokenExpires); err == nil { session.FlashError(w, r, "Error sending an email: %s", err)
err := mail.Send(mail.Message{
To: email,
Subject: "Verify your e-mail address",
Template: "email/verify_email.html",
Data: map[string]interface{}{
"Title": config.Title,
"URL": config.Current.BaseURL + "/signup?token=" + token.Token,
},
})
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) 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)
// Reminder to check their spam folder too (Gmail users)
session.Flash(w, r, "If you don't see the confirmation e-mail, check in case it went to your spam folder.")
templates.Redirect(w, r.URL.Path) templates.Redirect(w, r.URL.Path)
return return
} }
@ -240,6 +205,7 @@ func Signup() http.HandlerFunc {
} }
// Full sign-up step (w/ email verification token), validate more things. // Full sign-up step (w/ email verification token), validate more things.
var hasError bool
if len(password) < 3 { if len(password) < 3 {
session.FlashError(w, r, "Please enter a password longer than 3 characters.") session.FlashError(w, r, "Please enter a password longer than 3 characters.")
hasError = true hasError = true
@ -248,9 +214,8 @@ func Signup() http.HandlerFunc {
hasError = true hasError = true
} }
// Validate the username is OK: well formatted, not reserved, not existing. if !config.UsernameRegexp.MatchString(username) {
if err := models.IsValidUsername(username); err != nil { session.FlashError(w, r, "Your username must consist of only numbers, letters, - . and be 3-32 characters.")
session.FlashError(w, r, err.Error())
hasError = true hasError = true
} }

View File

@ -3,6 +3,7 @@ package account
import ( import (
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"strconv" "strconv"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
@ -13,15 +14,18 @@ import (
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
) )
var NotesURLRegexp = regexp.MustCompile(`^/notes/u/([^@]+?)$`)
// User notes page (/notes/u/username) // User notes page (/notes/u/username)
func UserNotes() http.HandlerFunc { func UserNotes() http.HandlerFunc {
tmpl := templates.Must("account/user_notes.html") tmpl := templates.Must("account/user_notes.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the username out of the URL parameters. // Parse the username out of the URL parameters.
var ( var username string
username = r.PathValue("username") m := NotesURLRegexp.FindStringSubmatch(r.URL.Path)
show = r.FormValue("show") // admin feedback filter if m != nil {
) username = m[1]
}
// Find this user. // Find this user.
user, err := models.FindUser(username) user, err := models.FindUser(username)
@ -111,7 +115,7 @@ func UserNotes() http.HandlerFunc {
} }
// Paginate feedback & reports. // Paginate feedback & reports.
if fb, err := models.PaginateFeedbackAboutUser(user, show, fbPager); err != nil { if fb, err := models.PaginateFeedbackAboutUser(user, fbPager); err != nil {
session.FlashError(w, r, "Paginating feedback on this user: %s", err) session.FlashError(w, r, "Paginating feedback on this user: %s", err)
} else { } else {
feedback = fb feedback = fb
@ -144,7 +148,6 @@ func UserNotes() http.HandlerFunc {
"MyNote": myNote, "MyNote": myNote,
// Admin concerns. // Admin concerns.
"Show": show,
"Feedback": feedback, "Feedback": feedback,
"FeedbackPager": fbPager, "FeedbackPager": fbPager,
"OtherNotes": otherNotes, "OtherNotes": otherNotes,
@ -205,7 +208,7 @@ func MyNotes() http.HandlerFunc {
} }
// Admin notes? // Admin notes?
if adminNotes && !currentUser.HasAdminScope(config.ScopeUserNotes) { if adminNotes && !currentUser.IsAdmin {
adminNotes = false adminNotes = false
} }

View File

@ -1,62 +0,0 @@
package admin
import (
"net/http"
"net/mail"
"strings"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// Manually create new user accounts.
func AddUser() http.HandlerFunc {
tmpl := templates.Must("admin/add_user.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var (
email = strings.TrimSpace(strings.ToLower(r.PostFormValue("email")))
username = strings.TrimSpace(strings.ToLower(r.PostFormValue("username")))
password = r.PostFormValue("password")
)
// Validate the email.
if _, err := mail.ParseAddress(email); err != nil {
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
// Password check.
if len(password) < 3 {
session.FlashError(w, r, "The password is required to be 3+ characters long.")
templates.Redirect(w, r.URL.Path)
return
}
// Validate the username is OK: well formatted, not reserved, not existing.
if err := models.IsValidUsername(username); err != nil {
session.FlashError(w, r, err.Error())
templates.Redirect(w, r.URL.Path)
return
}
// Create the user.
if _, err := models.CreateUser(username, email, password); err != nil {
session.FlashError(w, r, "Couldn't create the user: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
session.Flash(w, r, "Created the username %s with password: %s", username, password)
templates.Redirect(w, r.URL.Path)
return
}
if err := tmpl.Execute(w, r, nil); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -1,128 +0,0 @@
package admin
import (
"net/http"
"strconv"
"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"
)
// ChangeLog controller (/admin/changelog)
func ChangeLog() http.HandlerFunc {
tmpl := templates.Must("admin/change_log.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 parameters.
var (
tableName = r.FormValue("table_name")
tableID uint64
aboutUserID uint64
aboutUser = r.FormValue("about_user_id")
adminUserID uint64
adminUser = r.FormValue("admin_user_id")
event = r.FormValue("event")
sort = r.FormValue("sort")
searchQuery = r.FormValue("search")
search = models.ParseSearchString(searchQuery)
sortOK bool
)
// Sort options.
for _, v := range sortWhitelist {
if sort == v {
sortOK = true
break
}
}
if !sortOK {
sort = "created_at desc"
}
if i, err := strconv.Atoi(r.FormValue("table_id")); err == nil {
tableID = uint64(i)
}
// User IDs can be string values to look up by username or email address.
if aboutUser != "" {
if i, err := strconv.Atoi(aboutUser); err == nil {
aboutUserID = uint64(i)
} else {
if user, err := models.FindUser(aboutUser); err == nil {
aboutUserID = user.ID
} else {
session.FlashError(w, r, "Couldn't find About User ID: %s", err)
}
}
}
if adminUser != "" {
if i, err := strconv.Atoi(adminUser); err == nil {
adminUserID = uint64(i)
} else {
if user, err := models.FindUser(adminUser); err == nil {
adminUserID = user.ID
} else {
session.FlashError(w, r, "Couldn't find Admin User ID: %s", err)
}
}
}
pager := &models.Pagination{
PerPage: config.PageSizeChangeLog,
Sort: sort,
}
pager.ParsePage(r)
cl, err := models.PaginateChangeLog(tableName, tableID, aboutUserID, adminUserID, event, search, pager)
if err != nil {
session.FlashError(w, r, "Error paginating the change log: %s", err)
}
// Map the various user IDs.
var (
userIDs = []uint64{}
)
for _, row := range cl {
if row.AboutUserID > 0 {
userIDs = append(userIDs, row.AboutUserID)
}
if row.AdminUserID > 0 {
userIDs = append(userIDs, row.AdminUserID)
}
}
userMap, err := models.MapUsers(nil, userIDs)
if err != nil {
session.FlashError(w, r, "Error mapping user IDs: %s", err)
}
var vars = map[string]interface{}{
"ChangeLog": cl,
"TableNames": models.ChangeLogTables(),
"EventTypes": models.ChangeLogEventTypes,
"Pager": pager,
"UserMap": userMap,
// Filters
"TableName": tableName,
"TableID": tableID,
"AboutUserID": aboutUser,
"AdminUserID": adminUser,
"Event": event,
"SearchQuery": searchQuery,
"Sort": sort,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -3,7 +3,6 @@ package admin
import ( import (
"net/http" "net/http"
"code.nonshy.com/nonshy/website/pkg/encryption/coldstorage"
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
) )
@ -11,10 +10,7 @@ import (
func Dashboard() http.HandlerFunc { func Dashboard() http.HandlerFunc {
tmpl := templates.Must("admin/dashboard.html") tmpl := templates.Must("admin/dashboard.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var vars = map[string]interface{}{ if err := tmpl.Execute(w, r, nil); err != nil {
"ColdStorageWarning": coldstorage.Warning(),
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }

View File

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

View File

@ -1,43 +0,0 @@
package admin
import (
"net/http"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// Admin transparency page that lists the scopes and permissions an admin account has for all to see.
func Transparency() http.HandlerFunc {
tmpl := templates.Must("admin/transparency.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
username = r.PathValue("username")
)
// Get this user.
user, err := models.FindUser(username)
if err != nil {
templates.NotFoundPage(w, r)
return
}
// Only for admin user accounts.
if !user.IsAdmin {
templates.NotFoundPage(w, r)
return
}
// Template variables.
var vars = map[string]interface{}{
"User": user,
"AdminScopes": config.ListAdminScopes(),
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -1,14 +1,11 @@
package admin package admin
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"code.nonshy.com/nonshy/website/pkg/chat"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/models/deletion" "code.nonshy.com/nonshy/website/pkg/models/deletion"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
@ -27,14 +24,6 @@ func MarkPhotoExplicit() http.HandlerFunc {
next = "/" next = "/"
} }
// Get current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Failed to get current user: %s", err)
templates.Redirect(w, "/")
return
}
if idInt, err := strconv.Atoi(r.FormValue("photo_id")); err == nil { if idInt, err := strconv.Atoi(r.FormValue("photo_id")); err == nil {
photoID = uint64(idInt) photoID = uint64(idInt)
} else { } else {
@ -52,18 +41,11 @@ func MarkPhotoExplicit() http.HandlerFunc {
} }
photo.Explicit = true photo.Explicit = true
photo.Flagged = true
if err := photo.Save(); err != nil { if err := photo.Save(); err != nil {
session.FlashError(w, r, "Couldn't save photo: %s", err) session.FlashError(w, r, "Couldn't save photo: %s", err)
} else { } else {
session.Flash(w, r, "Marked photo as Explicit!") session.Flash(w, r, "Marked photo as Explicit!")
} }
// Log the change.
models.LogUpdated(&models.User{ID: photo.UserID}, currentUser, "photos", photo.ID, "Marked explicit by admin action.", []models.FieldDiff{
models.NewFieldDiff("Explicit", false, true),
})
templates.Redirect(w, next) templates.Redirect(w, next)
}) })
} }
@ -118,88 +100,11 @@ func UserActions() http.HandlerFunc {
return return
} }
// Get their block lists.
insights, err := models.GetBlocklistInsights(user) insights, err := models.GetBlocklistInsights(user)
if err != nil { if err != nil {
session.FlashError(w, r, "Error getting blocklist insights: %s", err) session.FlashError(w, r, "Error getting blocklist insights: %s", err)
} }
vars["BlocklistInsights"] = insights vars["BlocklistInsights"] = insights
// Also surface counts of admin blocks.
count, total := models.CountBlockedAdminUsers(user)
vars["AdminBlockCount"] = count
vars["AdminBlockTotal"] = total
case "chat.rules":
// Chat Moderation Rules.
if !currentUser.HasAdminScope(config.ScopeChatModerator) {
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeChatModerator)
templates.Redirect(w, "/admin")
return
}
if r.Method == http.MethodPost {
// Rules list for the change log.
var newRules = "(none)"
if rule, ok := r.PostForm["rules"]; ok && len(rule) > 0 {
newRules = strings.Join(rule, ",")
user.SetProfileField("chat_moderation_rules", newRules)
if err := user.Save(); err != nil {
session.FlashError(w, r, "Error saving the user's chat rules: %s", err)
} else {
session.Flash(w, r, "Chat moderation rules have been updated!")
}
} else {
user.DeleteProfileField("chat_moderation_rules")
session.Flash(w, r, "All chat moderation rules have been cleared for user: %s", user.Username)
}
templates.Redirect(w, "/u/"+user.Username)
// Log the new rules to the changelog.
models.LogEvent(
user,
currentUser,
"updated",
"chat.rules",
user.ID,
fmt.Sprintf(
"An admin has updated the chat moderation rules for this user.\n\n"+
"The update rules are: %s",
newRules,
),
)
return
}
vars["ChatModerationRules"] = config.ChatModerationRules
case "essays":
// Edit their profile essays easily.
if !currentUser.HasAdminScope(config.ScopePhotoModerator) {
session.FlashError(w, r, "Missing admin scope: %s", config.ScopePhotoModerator)
templates.Redirect(w, "/admin")
return
}
if r.Method == http.MethodPost {
var (
about = r.PostFormValue("about_me")
interests = r.PostFormValue("interests")
musicMovies = r.PostFormValue("music_movies")
)
user.SetProfileField("about_me", about)
user.SetProfileField("interests", interests)
user.SetProfileField("music_movies", musicMovies)
if err := user.Save(); err != nil {
session.FlashError(w, r, "Error saving the user: %s", err)
} else {
session.Flash(w, r, "Their profile text has been updated!")
}
templates.Redirect(w, "/u/"+user.Username)
return
}
case "impersonate": case "impersonate":
// Scope check. // Scope check.
if !currentUser.HasAdminScope(config.ScopeUserImpersonate) { if !currentUser.HasAdminScope(config.ScopeUserImpersonate) {
@ -236,14 +141,6 @@ func UserActions() http.HandlerFunc {
user.Save() user.Save()
session.Flash(w, r, "User ban status updated!") session.Flash(w, r, "User ban status updated!")
templates.Redirect(w, "/u/"+user.Username) templates.Redirect(w, "/u/"+user.Username)
// Maybe kick them from chat room now.
if _, err := chat.MaybeDisconnectUser(user); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
}
// Log the change.
models.LogEvent(user, currentUser, models.ChangeLogBanned, "users", currentUser.ID, fmt.Sprintf("User ban status updated to: %s", status))
return return
} }
case "promote": case "promote":
@ -259,34 +156,6 @@ func UserActions() http.HandlerFunc {
user.IsAdmin = action == "promote" user.IsAdmin = action == "promote"
user.Save() user.Save()
session.Flash(w, r, "User admin status updated!") session.Flash(w, r, "User admin status updated!")
templates.Redirect(w, "/u/"+user.Username)
// Log the change.
models.LogEvent(user, currentUser, models.ChangeLogAdmin, "users", currentUser.ID, fmt.Sprintf("User admin status updated to: %s", action))
return
}
case "password":
// Scope check.
if !currentUser.HasAdminScope(config.ScopeUserPassword) {
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeUserPassword)
templates.Redirect(w, "/admin")
return
}
if confirm {
password := r.PostFormValue("password")
if len(password) < 3 {
session.FlashError(w, r, "A password of at least 3 characters is required.")
templates.Redirect(w, r.URL.Path+fmt.Sprintf("?intent=password&user_id=%d", user.ID))
return
}
if err := user.SaveNewPassword(password); err != nil {
session.FlashError(w, r, "Failed to set the user's password: %s", err)
} else {
session.Flash(w, r, "The user's password has been updated to: %s", password)
}
templates.Redirect(w, "/u/"+user.Username) templates.Redirect(w, "/u/"+user.Username)
return return
} }
@ -305,14 +174,6 @@ func UserActions() http.HandlerFunc {
session.Flash(w, r, "User has been deleted!") session.Flash(w, r, "User has been deleted!")
} }
templates.Redirect(w, "/admin") templates.Redirect(w, "/admin")
// Kick them from the chat room if they are online.
if _, err := chat.DisconnectUserNow(user, "You have been signed out of chat because your account has been deleted."); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
}
// Log the change.
models.LogDeleted(nil, currentUser, "users", user.ID, fmt.Sprintf("Username %s has been deleted by an admin.", user.Username), nil)
return return
} }
default: default:

View File

@ -79,9 +79,6 @@ func Report() http.HandlerFunc {
log.Debug("Got chat report: %+v", report) log.Debug("Got chat report: %+v", report)
// Make a clickable profile link for the channel ID (other user).
otherUsername := strings.TrimPrefix(report.Channel, "@")
// Create an admin Feedback model. // Create an admin Feedback model.
fb := &models.Feedback{ fb := &models.Feedback{
Intent: "report", Intent: "report",
@ -90,7 +87,7 @@ func Report() http.HandlerFunc {
"A message was reported on the chat room!\n\n"+ "A message was reported on the chat room!\n\n"+
"* From username: [%s](/u/%s)\n"+ "* From username: [%s](/u/%s)\n"+
"* About username: [%s](/u/%s)\n"+ "* About username: [%s](/u/%s)\n"+
"* Channel: [**%s**](/u/%s)\n"+ "* Channel: **%s**\n"+
"* Timestamp: %s\n"+ "* Timestamp: %s\n"+
"* Classification: %s\n"+ "* Classification: %s\n"+
"* User comment: %s\n\n"+ "* User comment: %s\n\n"+
@ -98,7 +95,7 @@ func Report() http.HandlerFunc {
"The reported message on chat was:\n\n%s", "The reported message on chat was:\n\n%s",
report.FromUsername, report.FromUsername, report.FromUsername, report.FromUsername,
report.AboutUsername, report.AboutUsername, report.AboutUsername, report.AboutUsername,
report.Channel, otherUsername, report.Channel,
report.Timestamp, report.Timestamp,
report.Reason, report.Reason,
report.Comment, report.Comment,
@ -119,7 +116,6 @@ func Report() http.HandlerFunc {
if err == nil { if err == nil {
fb.TableName = "users" fb.TableName = "users"
fb.TableID = targetUser.ID fb.TableID = targetUser.ID
fb.AboutUserID = targetUser.ID
} else { } else {
log.Error("BareRTC Chat Feedback: couldn't find user ID for AboutUsername=%s: %s", report.AboutUsername, err) log.Error("BareRTC Chat Feedback: couldn't find user ID for AboutUsername=%s: %s", report.AboutUsername, err)
} }
@ -197,20 +193,12 @@ func Profile() http.HandlerFunc {
var photoCount = models.CountPublicPhotos(currentUser.ID) var photoCount = models.CountPublicPhotos(currentUser.ID)
// Member Since date.
var memberSinceDate = currentUser.CreatedAt
if currentUser.Certified {
if dt, err := currentUser.CertifiedSince(); err == nil {
memberSinceDate = dt
}
}
var resp = Response{ var resp = Response{
OK: true, OK: true,
ProfileFields: []ProfileField{ ProfileFields: []ProfileField{
{ {
Name: "Certified since", Name: "Member Since",
Value: fmt.Sprintf("%s ago", utility.FormatDurationCoarse(time.Since(memberSinceDate))), Value: fmt.Sprintf("%s ago", utility.FormatDurationCoarse(time.Since(currentUser.CreatedAt))),
}, },
{ {
Name: "📸 Gallery", Name: "📸 Gallery",

View File

@ -49,17 +49,3 @@ func SendJSON(w http.ResponseWriter, statusCode int, v interface{}) {
w.WriteHeader(statusCode) w.WriteHeader(statusCode)
w.Write(buf) w.Write(buf)
} }
// SendRawJSON response without the standard API wrapper.
func SendRawJSON(w http.ResponseWriter, statusCode int, v interface{}) {
buf, err := json.Marshal(v)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
w.Write(buf)
}

View File

@ -8,7 +8,6 @@ import (
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/photo"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
) )
@ -97,20 +96,25 @@ func Likes() http.HandlerFunc {
case "photos": case "photos":
if photo, err := models.GetPhoto(tableID); err == nil { if photo, err := models.GetPhoto(tableID); err == nil {
if user, err := models.GetUser(photo.UserID); err == nil { if user, err := models.GetUser(photo.UserID); err == nil {
// Safety check: if the current user should not see this picture, they can not "Like" it. // Admin safety check: in case the admin clicked 'Like' on a friends-only or private
// Example: you unfriended them but they still had the image on their old browser page. // picture they shouldn't have been expected to see, do not log a like.
if ok, _ := photo.ShouldBeSeenBy(currentUser); !ok { if currentUser.IsAdmin && 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)) {
SendJSON(w, http.StatusForbidden, Response{
Error: "You are not allowed to like that photo.",
})
return
}
}
// Blocking safety check: if either user blocks the other, liking is not allowed.
if models.IsBlocking(currentUser.ID, user.ID) {
SendJSON(w, http.StatusForbidden, Response{ SendJSON(w, http.StatusForbidden, Response{
Error: "You are not allowed to like that photo.", Error: "You are not allowed to like that photo.",
}) })
return return
} }
// Mark this photo as 'viewed' if it received a like.
// Example: on a gallery view the photo is only 'viewed' if interacted with (lightbox),
// going straight for the 'Like' button should count as well.
photo.View(currentUser)
targetUser = user targetUser = user
} }
} else { } else {
@ -120,6 +124,7 @@ func Likes() http.HandlerFunc {
log.Error("subject is users, find %d", tableID) log.Error("subject is users, find %d", tableID)
if user, err := models.GetUser(tableID); err == nil { if user, err := models.GetUser(tableID); err == nil {
targetUser = user targetUser = user
log.Warn("found user %s", targetUser.Username)
// Blocking safety check: if either user blocks the other, liking is not allowed. // Blocking safety check: if either user blocks the other, liking is not allowed.
if models.IsBlocking(currentUser.ID, user.ID) { if models.IsBlocking(currentUser.ID, user.ID) {
@ -170,7 +175,7 @@ func Likes() http.HandlerFunc {
} }
// Remove the target's notification about this like. // Remove the target's notification about this like.
models.RemoveSpecificNotificationAboutUser(targetUser.ID, currentUser.ID, models.NotificationLike, req.TableName, tableID) models.RemoveSpecificNotification(targetUser.ID, models.NotificationLike, req.TableName, tableID)
} else { } else {
if err := models.AddLike(currentUser, req.TableName, tableID); err != nil { if err := models.AddLike(currentUser, req.TableName, tableID); err != nil {
SendJSON(w, http.StatusBadRequest, Response{ SendJSON(w, http.StatusBadRequest, Response{
@ -197,13 +202,6 @@ func Likes() http.HandlerFunc {
} }
} }
// Refresh cached like counts.
if req.TableName == "photos" {
if err := models.UpdatePhotoCachedCounts(tableID); err != nil {
log.Error("UpdatePhotoCachedCount(%d): %s", tableID, err)
}
}
// Send success response. // Send success response.
SendJSON(w, http.StatusOK, Response{ SendJSON(w, http.StatusOK, Response{
OK: true, OK: true,
@ -286,7 +284,7 @@ func WhoLikes() http.HandlerFunc {
for _, user := range users { for _, user := range users {
result = append(result, Liker{ result = append(result, Liker{
Username: user.Username, Username: user.Username,
Avatar: photo.VisibleAvatarURL(user, currentUser), Avatar: user.VisibleAvatarURL(currentUser),
Relationship: user.UserRelationship, Relationship: user.UserRelationship,
}) })
} }

View File

@ -1,149 +0,0 @@
package api
import (
"fmt"
"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/ratelimit"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// User endpoint to flag other photos as explicit on their behalf.
func MarkPhotoExplicit() http.HandlerFunc {
// Request JSON schema.
type Request struct {
PhotoID uint64 `json:"photoID"`
Reason string `json:"reason"`
Other string `json:"other"`
}
// Response JSON schema.
type Response struct {
OK bool `json:"OK"`
Error string `json:"error,omitempty"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Failed to get current user: %s", err)
templates.Redirect(w, "/")
return
}
// Parse request payload.
var req Request
if err := ParseJSON(r, &req); err != nil {
SendJSON(w, http.StatusBadRequest, Response{
Error: fmt.Sprintf("Error with request payload: %s", err),
})
return
}
// Form validation.
if req.Reason == "" {
SendJSON(w, http.StatusBadRequest, Response{
Error: "Please select one of the reasons why this photo should've been marked Explicit.",
})
return
}
// Get this photo.
photo, err := models.GetPhoto(req.PhotoID)
if err != nil {
SendJSON(w, http.StatusBadRequest, Response{
Error: "That photo was not found!",
})
return
}
if !photo.Explicit {
// Rate limit how frequently they are tagging photos, in case a user is just going around
// and tagging EVERYTHING.
if !currentUser.IsAdmin {
limiter := &ratelimit.Limiter{
Namespace: "mark_explicit",
ID: currentUser.ID,
Limit: config.MarkExplicitRateLimit,
Window: config.MarkExplicitRateLimitWindow,
CooldownAt: config.MarkExplicitRateLimitCooldownAt,
Cooldown: config.MarkExplicitRateLimitCooldown,
}
if err := limiter.Ping(); err != nil {
SendJSON(w, http.StatusTooManyRequests, Response{
Error: "We appreciate the enthusiasm, but you seem to be marking an unusually high number of photos!\n\n" + err.Error(),
})
return
}
}
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),
})
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 {
fb := &models.Feedback{
Intent: "report",
Subject: "User flagged an explicit photo",
UserID: currentUser.ID,
TableName: "photos",
TableID: photo.ID,
Message: fmt.Sprintf(
"A user has flagged that a photo should have been marked as Explicit.\n\n"+
"* Reported by: %s (ID %d)\n"+
"* Reason given: %s\n"+
"* Elaboration (if other): %s\n\n"+
"The photo had been immediately marked as Explicit.",
currentUser.Username,
currentUser.ID,
req.Reason,
req.Other,
),
}
// Save the feedback.
if err := models.CreateFeedback(fb); err != nil {
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
}
}
}
// Log the change.
models.LogUpdated(&models.User{ID: photo.UserID}, currentUser, "photos", photo.ID, "Marked explicit by admin action.", []models.FieldDiff{
models.NewFieldDiff("Explicit", false, true),
})
SendJSON(w, http.StatusOK, Response{
OK: true,
})
})
}

View File

@ -0,0 +1,93 @@
package api
import (
"fmt"
"net/http"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/photo"
)
// RemoveOrphanedCommentPhotos API.
//
// URL: /v1/comment-photos/remove-orphaned
//
// Query parameters: ?apiKey={CronAPIKey}
//
// This endpoint looks for CommentPhotos having a blank CommentID that were created older
// than 24 hours ago and removes them. Configure the "CronAPIKey" in your settings.json
// and pass it as the query parameter.
func RemoveOrphanedCommentPhotos() http.HandlerFunc {
// Response JSON schema.
type Response struct {
OK bool `json:"OK"`
Error string `json:"error,omitempty"`
Total int64 `json:"total"`
Removed int `json:"removed"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
SendJSON(w, http.StatusNotAcceptable, Response{
Error: "GET method only",
})
return
}
// Get and validate the API key.
var (
apiKey = r.FormValue("apiKey")
compare = config.Current.CronAPIKey
)
if compare == "" {
SendJSON(w, http.StatusInternalServerError, Response{
OK: false,
Error: "app CronAPIKey is not configured",
})
return
} else if apiKey == "" || apiKey != compare {
SendJSON(w, http.StatusInternalServerError, Response{
OK: false,
Error: "invalid apiKey query parameter",
})
return
}
// Do the needful.
photos, total, err := models.GetOrphanedCommentPhotos()
if err != nil {
SendJSON(w, http.StatusInternalServerError, Response{
OK: false,
Error: fmt.Sprintf("GetOrphanedCommentPhotos: %s", err),
})
return
}
for _, row := range photos {
if err := photo.Delete(row.Filename); err != nil {
SendJSON(w, http.StatusInternalServerError, Response{
OK: false,
Error: fmt.Sprintf("Photo ID %d: removing file %s: %s", row.ID, row.Filename, err),
})
return
}
if err := row.Delete(); err != nil {
SendJSON(w, http.StatusInternalServerError, Response{
OK: false,
Error: fmt.Sprintf("DeleteOrphanedCommentPhotos(%d): %s", row.ID, err),
})
return
}
}
// Send success response.
SendJSON(w, http.StatusOK, Response{
OK: true,
Total: total,
Removed: len(photos),
})
})
}

View File

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

View File

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

View File

@ -1,34 +0,0 @@
package api
import (
"net/http"
"code.nonshy.com/nonshy/website/pkg/models"
)
// WorldCities API searches the location database for a world city location.
func WorldCities() http.HandlerFunc {
type Result struct {
ID uint64 `json:"id"`
Value string `json:"value"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var query = r.FormValue("query")
if query == "" {
SendRawJSON(w, http.StatusOK, []Result{})
return
}
result, err := models.SearchWorldCities(query)
if err != nil {
SendRawJSON(w, http.StatusInternalServerError, []Result{{
ID: 1,
Value: err.Error(),
}})
return
}
SendRawJSON(w, http.StatusOK, result)
})
}

View File

@ -97,9 +97,6 @@ func BlockUser() http.HandlerFunc {
session.FlashError(w, r, "Couldn't unblock this user: %s.", err) session.FlashError(w, r, "Couldn't unblock this user: %s.", err)
} else { } else {
session.Flash(w, r, "You have removed %s from your block list.", user.Username) session.Flash(w, r, "You have removed %s from your block list.", user.Username)
// Log the change.
models.LogDeleted(currentUser, nil, "blocks", user.ID, "Unblocked user "+user.Username+".", nil)
} }
templates.Redirect(w, "/users/blocked") templates.Redirect(w, "/users/blocked")
return return
@ -112,35 +109,17 @@ func BlockUser() http.HandlerFunc {
return return
} }
// If the target user is an admin, log this to the admin reports page. // Can't block admins who have the unblockable scope.
if user.IsAdmin { if user.IsAdmin && user.HasAdminScope(config.ScopeUnblockable) {
// Is the target admin user unblockable?
var (
unblockable = user.HasAdminScope(config.ScopeUnblockable)
footer string // qualifier for the admin report body
)
// Add a footer to the report to indicate whether the block goes through.
if unblockable {
footer = "**Unblockable:** this admin can not be blocked, so the block was not added and the user was shown an error message."
} else {
footer = "**Notice:** This admin is not unblockable, so the block has been added successfully."
}
// Also, include this user's current count of blocked admin users.
count, total := models.CountBlockedAdminUsers(currentUser)
footer += fmt.Sprintf("\n\nThis user now blocks %d of %d admin user(s) on this site.", count+1, total)
// For curiosity's sake, log a report. // For curiosity's sake, log a report.
fb := &models.Feedback{ fb := &models.Feedback{
Intent: "report", Intent: "report",
Subject: "A user tried to block an admin", Subject: "A user tried to block an admin",
Message: fmt.Sprintf( Message: fmt.Sprintf(
"A user has tried to block an admin user account!\n\n"+ "A user has tried to block an admin user account!\n\n"+
"* Username: %s\n* Tried to block: %s\n\n%s", "* Username: %s\n* Tried to block: %s",
currentUser.Username, currentUser.Username,
user.Username, user.Username,
footer,
), ),
UserID: currentUser.ID, UserID: currentUser.ID,
TableName: "users", TableName: "users",
@ -150,12 +129,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) 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. session.FlashError(w, r, "You can not block site administrators.")
if unblockable { templates.Redirect(w, "/u/"+username)
session.FlashError(w, r, "You can not block site administrators.") return
templates.Redirect(w, "/u/"+username)
return
}
} }
// Block the target user. // Block the target user.
@ -163,9 +139,6 @@ func BlockUser() http.HandlerFunc {
session.FlashError(w, r, "Couldn't block this user: %s.", err) session.FlashError(w, r, "Couldn't block this user: %s.", err)
} else { } else {
session.Flash(w, r, "You have added %s to your block list.", user.Username) session.Flash(w, r, "You have added %s to your block list.", user.Username)
// Log the change.
models.LogCreated(currentUser, "blocks", user.ID, "Blocks user "+user.Username+".")
} }
// Sync the block to the BareRTC chat server now, in case either user is currently online. // Sync the block to the BareRTC chat server now, in case either user is currently online.

View File

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

View File

@ -117,18 +117,7 @@ func PostComment() http.HandlerFunc {
session.FlashError(w, r, "Error deleting your commenting: %s", err) session.FlashError(w, r, "Error deleting your commenting: %s", err)
} else { } else {
session.Flash(w, r, "Your comment has been deleted.") session.Flash(w, r, "Your comment has been deleted.")
// 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) templates.Redirect(w, fromURL)
return return
} }
@ -162,9 +151,6 @@ func PostComment() http.HandlerFunc {
session.FlashError(w, r, "Couldn't save comment: %s", err) session.FlashError(w, r, "Couldn't save comment: %s", err)
} else { } else {
session.Flash(w, r, "Comment updated!") session.Flash(w, r, "Comment updated!")
// Log the change.
models.LogUpdated(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, "Updated a comment.\n\n---\n\n"+comment.Message, nil)
} }
templates.Redirect(w, fromURL) templates.Redirect(w, fromURL)
return return
@ -182,16 +168,6 @@ func PostComment() http.HandlerFunc {
session.Flash(w, r, "Comment added!") session.Flash(w, r, "Comment added!")
templates.Redirect(w, fromURL) templates.Redirect(w, fromURL)
// Refresh cached comment counts.
if tableName == "photos" {
if err := models.UpdatePhotoCachedCounts(tableID); err != nil {
log.Error("UpdatePhotoCachedCount(%d): %s", tableID, err)
}
}
// Log the change.
models.LogCreated(currentUser, "comments", comment.ID, "Posted a new comment.\n\n---\n\n"+message)
// Notify the recipient of the comment. // Notify the recipient of the comment.
if notifyUser != nil && notifyUser.ID != currentUser.ID && !notifyUser.NotificationOptOut(config.NotificationOptOutComments) { if notifyUser != nil && notifyUser.ID != currentUser.ID && !notifyUser.NotificationOptOut(config.NotificationOptOutComments) {
notif := &models.Notification{ notif := &models.Notification{

View File

@ -28,26 +28,12 @@ func Subscription() http.HandlerFunc {
templates.Redirect(w, "/") templates.Redirect(w, "/")
return return
} else { } else {
// Is the table_id expected to be a username? if idInt, err := strconv.Atoi(idStr); err != nil {
switch tableName { session.FlashError(w, r, "Comment table ID invalid.")
case "friend.photos": templates.Redirect(w, "/")
// Special "Friend uploaded a new photo" opt-out. return
if user, err := models.FindUser(idStr); err != nil { } else {
session.FlashError(w, r, "Username not found!") tableID = uint64(idInt)
templates.Redirect(w, "/")
return
} else {
tableID = user.ID
}
default:
// Integer IDs in all other cases.
if idInt, err := strconv.Atoi(idStr); err != nil {
session.FlashError(w, r, "Comment table ID invalid.")
templates.Redirect(w, "/")
return
} else {
tableID = uint64(idInt)
}
} }
} }
@ -61,7 +47,7 @@ func Subscription() http.HandlerFunc {
} }
// Validate everything else. // Validate everything else.
if _, ok := models.SubscribableTables[tableName]; !ok { if _, ok := models.CommentableTables[tableName]; !ok {
session.FlashError(w, r, "You can not comment on that.") session.FlashError(w, r, "You can not comment on that.")
templates.Redirect(w, "/") templates.Redirect(w, "/")
return return
@ -75,12 +61,6 @@ func Subscription() http.HandlerFunc {
return return
} }
// Language to use in the flash messages.
var kind = "comments"
if tableName == "friend.photos" {
kind = "new photo uploads"
}
// Get their subscription. // Get their subscription.
sub, err := models.GetSubscription(currentUser, tableName, tableID) sub, err := models.GetSubscription(currentUser, tableName, tableID)
if err != nil { if err != nil {
@ -89,15 +69,7 @@ func Subscription() http.HandlerFunc {
if _, err := models.SubscribeTo(currentUser, tableName, tableID); err != nil { if _, err := models.SubscribeTo(currentUser, tableName, tableID); err != nil {
session.FlashError(w, r, "Couldn't create subscription: %s", err) session.FlashError(w, r, "Couldn't create subscription: %s", err)
} else { } else {
session.Flash(w, r, "You will now be notified about %s on this page.", kind) session.Flash(w, r, "You will now be notified about comments on this page.")
}
} else {
// An explicit subscribe=false, may be a preemptive opt-out as in
// friend new photo notifications.
if _, err := models.UnsubscribeTo(currentUser, tableName, tableID); err != nil {
session.FlashError(w, r, "Couldn't create subscription: %s", err)
} else {
session.Flash(w, r, "You will no longer be notified about %s on this page.", kind)
} }
} }
} else { } else {
@ -107,9 +79,9 @@ func Subscription() http.HandlerFunc {
session.FlashError(w, r, "Couldn't save your subscription settings: %s", err) session.FlashError(w, r, "Couldn't save your subscription settings: %s", err)
} else { } else {
if subscribe { if subscribe {
session.Flash(w, r, "You will now be notified about %s on this page.", kind) session.Flash(w, r, "You will now be notified about comments on this page.")
} else { } else {
session.Flash(w, r, "You will no longer be notified about new %s on this page.", kind) session.Flash(w, r, "You will no longer be notified about new comments on this page.")
} }
} }
} }

View File

@ -1,7 +1,6 @@
package forum package forum
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@ -45,7 +44,7 @@ func AddEdit() http.HandlerFunc {
return return
} else { } else {
// Do we have permission? // Do we have permission?
if !found.CanEdit(currentUser) { if found.OwnerID != currentUser.ID && !currentUser.IsAdmin {
templates.ForbiddenPage(w, r) templates.ForbiddenPage(w, r)
return return
} }
@ -54,13 +53,6 @@ func AddEdit() http.HandlerFunc {
} }
} }
// If we are over our quota for User Forums, do not allow creating a new one.
if forum == nil && !currentUser.HasAdminScope(config.ScopeForumAdmin) && currentUser.ForumQuotaRemaining() <= 0 {
session.FlashError(w, r, "You do not currently have spare quota to create a new forum.")
templates.Redirect(w, "/forum/admin")
return
}
// Saving? // Saving?
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
var ( var (
@ -71,49 +63,29 @@ func AddEdit() http.HandlerFunc {
isExplicit = r.PostFormValue("explicit") == "true" isExplicit = r.PostFormValue("explicit") == "true"
isPrivileged = r.PostFormValue("privileged") == "true" isPrivileged = r.PostFormValue("privileged") == "true"
isPermitPhotos = r.PostFormValue("permit_photos") == "true" isPermitPhotos = r.PostFormValue("permit_photos") == "true"
isPrivate = r.PostFormValue("private") == "true" isInnerCircle = r.PostFormValue("inner_circle") == "true"
) )
// Sanity check admin-only settings -> default these to OFF. // Sanity check admin-only settings.
if !currentUser.HasAdminScope(config.ScopeForumAdmin) { if !currentUser.IsAdmin {
isPrivileged = false isPrivileged = false
isPrivate = false isPermitPhotos = false
} }
// Were we editing an existing forum? // Were we editing an existing forum?
if forum != nil { if forum != nil {
diffs := []models.FieldDiff{
models.NewFieldDiff("Title", forum.Title, title),
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.Title = title
forum.Description = description forum.Description = description
forum.Category = category forum.Category = category
forum.Explicit = isExplicit forum.Explicit = isExplicit
forum.Privileged = isPrivileged
forum.PermitPhotos = isPermitPhotos forum.PermitPhotos = isPermitPhotos
forum.InnerCircle = isInnerCircle
// 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("Private", forum.Private, isPrivate),
)
forum.Privileged = isPrivileged
forum.Private = isPrivate
}
// Save it. // Save it.
if err := forum.Save(); err == nil { if err := forum.Save(); err == nil {
session.Flash(w, r, "Forum has been updated!") session.Flash(w, r, "Forum has been updated!")
templates.Redirect(w, "/forum/admin") templates.Redirect(w, "/forum/admin")
// Log the change.
models.LogUpdated(currentUser, nil, "forums", forum.ID, "Updated the forum's settings.", diffs)
return return
} else { } else {
session.FlashError(w, r, "Error saving the forum: %s", err) session.FlashError(w, r, "Error saving the forum: %s", err)
@ -141,39 +113,12 @@ func AddEdit() http.HandlerFunc {
Explicit: isExplicit, Explicit: isExplicit,
Privileged: isPrivileged, Privileged: isPrivileged,
PermitPhotos: isPermitPhotos, PermitPhotos: isPermitPhotos,
Private: isPrivate, InnerCircle: isInnerCircle,
} }
if err := models.CreateForum(forum); err == nil { if err := models.CreateForum(forum); err == nil {
session.Flash(w, r, "The forum has been created!") session.Flash(w, r, "The forum has been created!")
templates.Redirect(w, "/forum/admin") templates.Redirect(w, "/forum/admin")
// Log the change.
models.LogCreated(currentUser, "forums", forum.ID, fmt.Sprintf(
"Created a new forum.\n\n"+
"* Category: %s\n"+
"* Title: %s\n"+
"* Fragment: %s\n"+
"* Description: %s\n"+
"* Explicit: %v\n"+
"* Privileged: %v\n"+
"* Photos: %v\n"+
"* Private: %v",
forum.Category,
forum.Title,
forum.Fragment,
forum.Description,
forum.Explicit,
forum.Privileged,
forum.PermitPhotos,
forum.Private,
))
// If this is a Community forum, subscribe the owner to it immediately.
if forum.Category == "" {
models.CreateForumMembership(currentUser, forum)
}
return return
} else { } else {
session.FlashError(w, r, "Error creating the forum: %s", err) session.FlashError(w, r, "Error creating the forum: %s", err)
@ -182,20 +127,12 @@ func AddEdit() http.HandlerFunc {
} }
} }
// Get the list of moderators. _ = editID
var mods []*models.User
if forum != nil {
mods, err = forum.GetModerators()
if err != nil {
session.FlashError(w, r, "Error getting moderators list: %s", err)
}
}
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"EditID": editID, "EditID": editID,
"EditForum": forum, "EditForum": forum,
"Categories": config.ForumCategories, "Categories": config.ForumCategories,
"Moderators": mods,
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -1,118 +0,0 @@
package forum
import (
"net/http"
"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"
)
// Explore all existing forums.
func Explore() http.HandlerFunc {
// This page shares a template with the board index (Categories) page.
tmpl := templates.Must("forum/index.html")
// Whitelist for ordering options.
var sortWhitelist = []string{
"created_at desc",
"created_at asc",
"title asc",
"title desc",
// Special sort handlers.
// See PaginateForums for expanded handlers for these.
"by_followers",
"by_latest",
"by_threads",
"by_posts",
"by_users",
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
searchTerm = r.FormValue("q")
search = models.ParseSearchString(searchTerm)
show = r.FormValue("show")
categories = []string{}
subscribed = r.FormValue("show") == "followed"
sort = r.FormValue("sort")
sortOK bool
)
// Sort options.
for _, v := range sortWhitelist {
if sort == v {
sortOK = true
break
}
}
if !sortOK {
sort = sortWhitelist[0]
}
// Set of forum categories to filter for.
switch show {
case "official":
categories = config.ForumCategories
case "community":
categories = []string{""}
}
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Couldn't get current user: %s", err)
templates.Redirect(w, "/")
return
}
var pager = &models.Pagination{
Page: 1,
PerPage: config.PageSizeBrowseForums,
Sort: sort,
}
pager.ParsePage(r)
// Browse all forums (no category filter for official)
forums, err := models.PaginateForums(currentUser, categories, search, subscribed, pager)
if err != nil {
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
templates.Redirect(w, "/")
return
}
// Bucket the forums into their categories for easy front-end.
categorized := models.CategorizeForums(forums, nil)
// Map statistics for these forums.
forumMap := models.MapForumStatistics(forums)
followMap := models.MapForumMemberships(currentUser, forums)
var vars = map[string]interface{}{
"CurrentForumTab": "explore",
"IsExploreTab": true,
"Pager": pager,
"Categories": categorized,
"ForumMap": forumMap,
"FollowMap": followMap,
"FollowersMap": models.MapForumFollowers(forums),
// Search filters
"SearchTerm": searchTerm,
"Show": show,
"Sort": sort,
// Current viewer's forum quota.
"ForumQuota": models.ComputeForumQuota(currentUser),
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -16,16 +16,21 @@ func Forum() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the path parameters // Parse the path parameters
var ( var (
fragment = r.PathValue("fragment") forum *models.Forum
forum *models.Forum
) )
// Look up the forum by its fragment. if m := ForumPathRegexp.FindStringSubmatch(r.URL.Path); m == nil {
if found, err := models.ForumByFragment(fragment); err != nil { log.Error("Regexp failed to parse: %s", r.URL.Path)
templates.NotFoundPage(w, r) templates.NotFoundPage(w, r)
return return
} else { } else {
forum = found // Look up the forum itself.
if found, err := models.ForumByFragment(m[1]); err != nil {
templates.NotFoundPage(w, r)
return
} else {
forum = found
}
} }
// Get the current user. // Get the current user.
@ -36,8 +41,8 @@ func Forum() http.HandlerFunc {
return return
} }
// Is it a private forum? // Is it an inner circle forum?
if !forum.CanBeSeenBy(currentUser) { if forum.InnerCircle && !currentUser.IsInnerCircle() {
templates.NotFoundPage(w, r) templates.NotFoundPage(w, r)
return return
} }
@ -55,7 +60,7 @@ func Forum() http.HandlerFunc {
var pager = &models.Pagination{ var pager = &models.Pagination{
Page: 1, Page: 1,
PerPage: config.PageSizeThreadList, PerPage: config.PageSizeThreadList,
Sort: "threads.updated_at desc", Sort: "updated_at desc",
} }
pager.ParsePage(r) pager.ParsePage(r)
@ -66,28 +71,17 @@ func Forum() http.HandlerFunc {
return return
} }
// Inject pinned threads on top of the first page. // Inject pinned threads on top.
if pager.Page == 1 { threads = append(pinned, threads...)
threads = append(pinned, threads...)
}
// Map the statistics (replies, views) of these threads. // Map the statistics (replies, views) of these threads.
threadMap := models.MapThreadStatistics(threads) threadMap := models.MapThreadStatistics(threads)
// Load the forum's moderators.
mods, err := forum.GetModerators()
if err != nil {
log.Error("Getting forum moderators: %s", err)
}
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"Forum": forum, "Forum": forum,
"ForumModerators": mods, "Threads": threads,
"ForumSubscriberCount": models.CountForumMemberships(forum), "ThreadMap": threadMap,
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum), "Pager": pager,
"Threads": threads,
"ThreadMap": threadMap,
"Pager": pager,
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -17,6 +17,17 @@ var (
FragmentRegexp = regexp.MustCompile( FragmentRegexp = regexp.MustCompile(
fmt.Sprintf(`^(%s)$`, FragmentPattern), fmt.Sprintf(`^(%s)$`, FragmentPattern),
) )
// Forum path parameters.
ForumPathRegexp = regexp.MustCompile(
fmt.Sprintf(`^/f/(%s)`, FragmentPattern),
)
ForumPostRegexp = regexp.MustCompile(
fmt.Sprintf(`^/f/(%s)/(post)`, FragmentPattern),
)
ForumThreadRegexp = regexp.MustCompile(
fmt.Sprintf(`^/f/(%s)/(thread)/(\d+)`, FragmentPattern),
)
) )
// Landing page for forums. // Landing page for forums.
@ -33,13 +44,14 @@ func Landing() http.HandlerFunc {
// Get all the categorized index forums. // Get all the categorized index forums.
// XXX: we get a large page size to get ALL official forums // XXX: we get a large page size to get ALL official forums
// This pager is hardcoded and doesn't parse from ?page= params. var pager = &models.Pagination{
var indexPager = &models.Pagination{
Page: 1, Page: 1,
PerPage: config.PageSizeForums, PerPage: config.PageSizeForums,
Sort: "title asc", Sort: "title asc",
} }
forums, err := models.PaginateForums(currentUser, config.ForumCategories, nil, false, indexPager) pager.ParsePage(r)
forums, err := models.PaginateForums(currentUser, config.ForumCategories, pager)
if err != nil { if err != nil {
session.FlashError(w, r, "Couldn't paginate forums: %s", err) session.FlashError(w, r, "Couldn't paginate forums: %s", err)
templates.Redirect(w, "/") templates.Redirect(w, "/")
@ -49,41 +61,13 @@ func Landing() http.HandlerFunc {
// Bucket the forums into their categories for easy front-end. // Bucket the forums into their categories for easy front-end.
categorized := models.CategorizeForums(forums, config.ForumCategories) categorized := models.CategorizeForums(forums, config.ForumCategories)
// Inject the "My List" Category if the user subscribes to forums.
var pager = &models.Pagination{
Page: 1,
PerPage: config.PageSizeMyListForums,
Sort: "by_latest",
}
pager.ParsePage(r)
if config.UserForumsEnabled {
myList, err := models.PaginateForums(currentUser, nil, nil, true, pager)
if err != nil {
session.FlashError(w, r, "Couldn't get your followed forums: %s", err)
} else if len(myList) > 0 {
forums = append(forums, myList...)
categorized = append([]*models.CategorizedForum{
{
Category: "My List",
Forums: myList,
},
}, categorized...)
}
}
// Map statistics for these forums. // Map statistics for these forums.
forumMap := models.MapForumStatistics(forums) forumMap := models.MapForumStatistics(forums)
followMap := models.MapForumMemberships(currentUser, forums)
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"Pager": pager, "Pager": pager,
"Categories": categorized, "Categories": categorized,
"ForumMap": forumMap, "ForumMap": forumMap,
"FollowMap": followMap,
"FollowersMap": models.MapForumFollowers(forums),
// Current viewer's forum quota.
"ForumQuota": models.ComputeForumQuota(currentUser),
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -12,40 +12,7 @@ import (
// Manage page for forums -- admin only for now but may open up later. // Manage page for forums -- admin only for now but may open up later.
func Manage() http.HandlerFunc { func Manage() http.HandlerFunc {
tmpl := templates.Must("forum/admin.html") tmpl := templates.Must("forum/admin.html")
// Whitelist for ordering options.
var sortWhitelist = []string{
"updated_at desc",
"created_at desc",
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
searchTerm = r.FormValue("q")
show = r.FormValue("show")
categories = []string{}
sort = r.FormValue("sort")
sortOK bool
)
// Sort options.
for _, v := range sortWhitelist {
if sort == v {
sortOK = true
break
}
}
if !sortOK {
sort = sortWhitelist[0]
}
// Show options.
if show == "official" {
categories = config.ForumCategories
} else if show == "community" {
categories = []string{""}
}
// Get the current user. // Get the current user.
currentUser, err := session.CurrentUser(r) currentUser, err := session.CurrentUser(r)
if err != nil { if err != nil {
@ -54,24 +21,15 @@ func Manage() http.HandlerFunc {
return return
} }
// Parse their search term.
var search = models.ParseSearchString(searchTerm)
// Get forums the user owns or can manage. // Get forums the user owns or can manage.
var pager = &models.Pagination{ var pager = &models.Pagination{
Page: 1, Page: 1,
PerPage: config.PageSizeForumAdmin, PerPage: config.PageSizeForumAdmin,
Sort: sort, Sort: "updated_at desc",
} }
pager.ParsePage(r) pager.ParsePage(r)
forums, err := models.PaginateOwnedForums( forums, err := models.PaginateOwnedForums(currentUser.ID, currentUser.IsAdmin, pager)
currentUser.ID,
currentUser.HasAdminScope(config.ScopeForumAdmin),
categories,
search,
pager,
)
if err != nil { if err != nil {
session.FlashError(w, r, "Couldn't paginate owned forums: %s", err) session.FlashError(w, r, "Couldn't paginate owned forums: %s", err)
templates.Redirect(w, "/") templates.Redirect(w, "/")
@ -81,15 +39,6 @@ func Manage() http.HandlerFunc {
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"Pager": pager, "Pager": pager,
"Forums": forums, "Forums": forums,
// Quote settings.
"QuotaLimit": models.ComputeForumQuota(currentUser),
"QuotaCount": models.CountOwnedUserForums(currentUser),
// Search filters.
"SearchTerm": searchTerm,
"Show": show,
"Sort": sort,
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -1,227 +0,0 @@
package forum
import (
"fmt"
"net/http"
"strconv"
"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"
)
// ManageModerators controller (/forum/admin/moderators) to appoint moderators to your (user) forum.
func ManageModerators() http.HandlerFunc {
// Reuse the upload page but with an EditPhoto variable.
tmpl := templates.Must("forum/moderators.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
intent = r.FormValue("intent")
stringID = r.FormValue("forum_id")
)
// Parse forum_id query parameter.
var forumID uint64
if stringID != "" {
if i, err := strconv.Atoi(stringID); err == nil {
forumID = uint64(i)
} else {
session.FlashError(w, r, "Edit parameter: forum_id was not an integer")
templates.Redirect(w, "/forum/admin")
return
}
}
// Redirect URLs
var (
next = fmt.Sprintf("%s?forum_id=%d", r.URL.Path, forumID)
nextFinished = fmt.Sprintf("/forum/admin/edit?id=%d", forumID)
)
// Load the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: could not get currentUser.")
templates.Redirect(w, "/")
return
}
// Are we adding/removing a user as moderator?
var (
username = r.FormValue("to")
user *models.User
)
if username != "" {
if found, err := models.FindUser(username); err != nil {
templates.NotFoundPage(w, r)
return
} else {
user = found
}
}
// Look up the forum by its fragment.
forum, err := models.GetForum(forumID)
if err != nil {
templates.NotFoundPage(w, r)
return
}
// User must be the owner of this forum, or a privileged admin.
if !forum.CanEdit(currentUser) {
templates.ForbiddenPage(w, r)
return
}
// The forum owner can not add themself.
if user != nil && forum.OwnerID == user.ID {
session.FlashError(w, r, "You can not add the forum owner to its moderators list.")
templates.Redirect(w, next)
return
}
// POSTing?
if r.Method == http.MethodPost {
switch intent {
case "submit":
// Confirmed adding a moderator.
if _, err := forum.AddModerator(user); err != nil {
session.FlashError(w, r, "Error adding the moderator: %s", err)
templates.Redirect(w, next)
return
}
// Create a notification for this.
notif := &models.Notification{
UserID: user.ID,
AboutUser: *currentUser,
Type: models.NotificationForumModerator,
TableName: "forums",
TableID: forum.ID,
Link: fmt.Sprintf("/f/%s", forum.Fragment),
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create PrivatePhoto notification: %s", err)
}
session.Flash(w, r, "%s has been added to the moderators list!", user.Username)
templates.Redirect(w, nextFinished)
return
case "confirm-remove":
// Confirm removing a moderator.
if _, err := forum.RemoveModerator(user); err != nil {
session.FlashError(w, r, "Error removing the moderator: %s", err)
templates.Redirect(w, next)
return
}
// Revoke any past notifications they had about being added as moderator.
if err := models.RemoveSpecificNotification(user.ID, models.NotificationForumModerator, "forums", forum.ID); err != nil {
log.Error("Couldn't revoke the forum moderator notification: %s", err)
}
session.Flash(w, r, "%s has been removed from the moderators list.", user.Username)
templates.Redirect(w, nextFinished)
return
}
}
var vars = map[string]interface{}{
"Forum": forum,
"User": user,
"IsRemoving": intent == "remove",
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
// ModerateThread endpoint - perform a mod action like pinning or locking a thread.
func ModerateThread() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query params.
var (
threadID, err = strconv.Atoi(r.PathValue("id"))
intent = r.PostFormValue("intent")
nextURL = fmt.Sprintf("/forum/thread/%d", threadID)
)
if err != nil {
session.FlashError(w, r, "Invalid thread ID.")
templates.Redirect(w, nextURL)
return
}
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Couldn't get current user: %s", err)
templates.Redirect(w, "/")
return
}
// Get this thread.
thread, err := models.GetThread(uint64(threadID))
if err != nil {
templates.NotFoundPage(w, r)
return
}
// Get its forum.
forum, err := models.GetForum(thread.ForumID)
if err != nil {
templates.NotFoundPage(w, r)
return
}
// User must at least be able to moderate.
if !forum.CanBeModeratedBy(currentUser) {
templates.ForbiddenPage(w, r)
return
}
// Does the user have Ownership level access (including privileged admins)
var isOwner = forum.OwnerID == currentUser.ID || currentUser.HasAdminScope(config.ScopeForumAdmin)
/****
* Moderator level permissions.
***/
switch intent {
case "lock":
thread.NoReply = true
session.Flash(w, r, "This thread has been locked and will not be accepting any new replies.")
case "unlock":
thread.NoReply = false
session.Flash(w, r, "This thread has been unlocked and can accept new replies again.")
default:
if !isOwner {
// End of the road.
templates.ForbiddenPage(w, r)
return
}
}
/****
* Owner + Admin level permissions.
***/
switch intent {
case "pin":
thread.Pinned = true
session.Flash(w, r, "This thread is now pinned to the top of the forum.")
case "unpin":
thread.Pinned = false
session.Flash(w, r, "This thread will no longer be pinned to the top of the forum.")
}
// Save changes to the thread.
if err := thread.Save(); err != nil {
session.FlashError(w, r, "Error saving thread: %s", err)
}
templates.Redirect(w, nextURL)
})
}

View File

@ -15,7 +15,6 @@ import (
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/photo" "code.nonshy.com/nonshy/website/pkg/photo"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/spam"
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
) )
@ -87,11 +86,6 @@ func NewPost() http.HandlerFunc {
} }
} }
// 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) ||
(forum.OwnerID == currentUser.ID && isDelete)
// Does the comment have an existing Photo ID? // Does the comment have an existing Photo ID?
if len(photoID) > 0 { if len(photoID) > 0 {
if i, err := strconv.Atoi(photoID); err == nil { if i, err := strconv.Atoi(photoID); err == nil {
@ -121,7 +115,7 @@ func NewPost() http.HandlerFunc {
comment = found comment = found
// Verify that it is indeed OUR comment. // Verify that it is indeed OUR comment.
if currentUser.ID != comment.UserID && !canModerate { if currentUser.ID != comment.UserID && !currentUser.HasAdminScope(config.ScopeForumModerator) {
templates.ForbiddenPage(w, r) templates.ForbiddenPage(w, r)
return return
} }
@ -167,11 +161,6 @@ func NewPost() http.HandlerFunc {
session.FlashError(w, r, "Error deleting your post: %s", err) session.FlashError(w, r, "Error deleting your post: %s", err)
} else { } else {
session.Flash(w, r, "Your post has been deleted.") session.Flash(w, r, "Your post has been deleted.")
// Log the change.
models.LogDeleted(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, fmt.Sprintf(
"Deleted a forum comment on thread %d forum /f/%s", thread.ID, forum.Fragment,
), comment)
} }
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID)) templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return return
@ -189,19 +178,6 @@ func NewPost() http.HandlerFunc {
// Submitting the form. // Submitting the form.
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
// Look for spammy links to video sites or things.
if err := spam.DetectSpamMessage(title + message); err != nil {
session.FlashError(w, r, err.Error())
if thread != nil {
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
} else if forum != nil {
templates.Redirect(w, fmt.Sprintf("/f/%s", forum.Fragment))
} else {
templates.Redirect(w, "/forum")
}
return
}
// Polls: parse form parameters into a neat list of answers. // Polls: parse form parameters into a neat list of answers.
pollExpires, _ = strconv.Atoi(r.FormValue("poll_expires")) pollExpires, _ = strconv.Atoi(r.FormValue("poll_expires"))
var distinctPollChoices = map[string]interface{}{} var distinctPollChoices = map[string]interface{}{}
@ -339,14 +315,6 @@ func NewPost() http.HandlerFunc {
session.FlashError(w, r, "Couldn't save comment: %s", err) session.FlashError(w, r, "Couldn't save comment: %s", err)
} else { } else {
session.Flash(w, r, "Comment updated!") session.Flash(w, r, "Comment updated!")
// Log the change.
models.LogUpdated(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, fmt.Sprintf(
"Edited their comment on thread %d (in /f/%s):\n\n%s",
thread.ID,
forum.Fragment,
message,
), nil)
} }
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID)) templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return return
@ -359,13 +327,6 @@ func NewPost() http.HandlerFunc {
} else { } else {
session.Flash(w, r, "Reply added to the thread!") session.Flash(w, r, "Reply added to the thread!")
// Log the change.
models.LogCreated(currentUser, "comments", reply.ID, fmt.Sprintf(
"Commented on thread %d:\n\n%s",
thread.ID,
message,
))
// If we're attaching a photo, link it to this reply CommentID. // If we're attaching a photo, link it to this reply CommentID.
if commentPhoto != nil { if commentPhoto != nil {
commentPhoto.CommentID = reply.ID commentPhoto.CommentID = reply.ID
@ -397,7 +358,7 @@ func NewPost() http.HandlerFunc {
TableName: "threads", TableName: "threads",
TableID: thread.ID, TableID: thread.ID,
Message: message, Message: message,
Link: fmt.Sprintf("/go/comment?id=%d", reply.ID), Link: fmt.Sprintf("/forum/thread/%d%s#p%d", thread.ID, queryString, reply.ID),
} }
if err := models.CreateNotification(notif); err != nil { if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create thread reply notification for subscriber %d: %s", userID, err) log.Error("Couldn't create thread reply notification for subscriber %d: %s", userID, err)
@ -467,18 +428,6 @@ func NewPost() http.HandlerFunc {
} }
} }
// Log the change.
models.LogCreated(currentUser, "threads", thread.ID, fmt.Sprintf(
"Started a new forum thread on forum /f/%s (%s)\n\n"+
"* Has poll? %v\n"+
"* Title: %s\n\n%s",
forum.Fragment,
forum.Title,
isPoll,
thread.Title,
message,
))
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID)) templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return return
} }

View File

@ -14,14 +14,6 @@ import (
func Newest() http.HandlerFunc { func Newest() http.HandlerFunc {
tmpl := templates.Must("forum/newest.html") tmpl := templates.Must("forum/newest.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query parameters.
var (
allComments = r.FormValue("all") == "true"
whichForums = r.FormValue("which")
categories = []string{}
subscribed bool
)
// Get the current user. // Get the current user.
currentUser, err := session.CurrentUser(r) currentUser, err := session.CurrentUser(r)
if err != nil { if err != nil {
@ -30,29 +22,6 @@ func Newest() http.HandlerFunc {
return return
} }
// Recall the user's default "Which forum:" answer if not selected.
if whichForums == "" {
whichForums = currentUser.GetProfileField("forum_newest_default")
if whichForums == "" {
whichForums = "official"
}
}
// Narrow down to which set of forums?
switch whichForums {
case "official":
categories = config.ForumCategories
case "community":
categories = []string{""}
case "followed":
subscribed = true
default:
whichForums = "all"
}
// Store their "Which forums" filter to be their new default view.
currentUser.SetProfileField("forum_newest_default", whichForums)
// Get all the categorized index forums. // Get all the categorized index forums.
var pager = &models.Pagination{ var pager = &models.Pagination{
Page: 1, Page: 1,
@ -60,7 +29,7 @@ func Newest() http.HandlerFunc {
} }
pager.ParsePage(r) pager.ParsePage(r)
posts, err := models.PaginateRecentPosts(currentUser, categories, subscribed, allComments, pager) posts, err := models.PaginateRecentPosts(currentUser, config.ForumCategories, pager)
if err != nil { if err != nil {
session.FlashError(w, r, "Couldn't paginate forums: %s", err) session.FlashError(w, r, "Couldn't paginate forums: %s", err)
templates.Redirect(w, "/") templates.Redirect(w, "/")
@ -78,14 +47,9 @@ func Newest() http.HandlerFunc {
} }
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"CurrentForumTab": "newest", "Pager": pager,
"Pager": pager, "RecentPosts": posts,
"RecentPosts": posts, "PhotoMap": photos,
"PhotoMap": photos,
// Filter options.
"WhichForums": whichForums,
"AllComments": allComments,
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -25,8 +25,6 @@ func Search() http.HandlerFunc {
searchTerm = r.FormValue("q") searchTerm = r.FormValue("q")
byUsername = r.FormValue("username") byUsername = r.FormValue("username")
postType = r.FormValue("type") postType = r.FormValue("type")
inForum = r.FormValue("in")
categories = []string{}
sort = r.FormValue("sort") sort = r.FormValue("sort")
sortOK bool sortOK bool
) )
@ -47,14 +45,6 @@ func Search() http.HandlerFunc {
postType = "all" postType = "all"
} }
// In forums
switch inForum {
case "official":
categories = config.ForumCategories
case "community":
categories = []string{""}
}
// Get the current user. // Get the current user.
currentUser, err := session.CurrentUser(r) currentUser, err := session.CurrentUser(r)
if err != nil { if err != nil {
@ -90,7 +80,7 @@ func Search() http.HandlerFunc {
) )
pager.ParsePage(r) pager.ParsePage(r)
posts, err := models.SearchForum(currentUser, categories, search, filters, pager) posts, err := models.SearchForum(currentUser, search, filters, pager)
if err != nil { if err != nil {
session.FlashError(w, r, "Couldn't search the forums: %s", err) session.FlashError(w, r, "Couldn't search the forums: %s", err)
templates.Redirect(w, "/") templates.Redirect(w, "/")
@ -110,16 +100,14 @@ func Search() http.HandlerFunc {
} }
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"CurrentForumTab": "search", "Pager": pager,
"Pager": pager, "Comments": posts,
"Comments": posts, "ThreadMap": threadMap,
"ThreadMap": threadMap, "PhotoMap": photos,
"PhotoMap": photos,
"SearchTerm": searchTerm, "SearchTerm": searchTerm,
"ByUsername": byUsername, "ByUsername": byUsername,
"Type": postType, "Type": postType,
"InForum": inForum,
"Sort": sort, "Sort": sort,
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {

View File

@ -1,75 +0,0 @@
package forum
import (
"net/http"
"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"
)
// Subscribe to a forum, adding it to your bookmark list.
func Subscribe() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the path parameters
var (
fragment = r.FormValue("fragment")
forum *models.Forum
intent = r.FormValue("intent")
)
// Look up the forum by its fragment.
if found, err := models.ForumByFragment(fragment); err != nil {
templates.NotFoundPage(w, r)
return
} else {
forum = found
}
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Couldn't get current user: %s", err)
templates.Redirect(w, "/")
return
}
switch intent {
case "follow":
// Is it a private forum?
if forum.Private && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
return
}
_, err := models.CreateForumMembership(currentUser, forum)
if err != nil {
session.FlashError(w, r, "Couldn't follow this forum: %s", err)
} else {
session.Flash(w, r, "You have added %s to your forum list.", forum.Title)
}
case "unfollow":
fm, err := models.GetForumMembership(currentUser, forum)
if err == nil {
// Were we a moderator previously? If so, revoke the notification about it.
if fm.IsModerator {
if err := models.RemoveSpecificNotification(currentUser.ID, models.NotificationForumModerator, "forums", forum.ID); err != nil {
log.Error("User unsubscribed from forum and couldn't remove their moderator notification: %s", err)
}
}
err = fm.Delete()
if err != nil {
session.FlashError(w, r, "Couldn't delete your forum membership: %s", err)
}
}
session.Flash(w, r, "You have removed %s from your forum list.", forum.Title)
default:
session.Flash(w, r, "Unknown intent.")
}
templates.Redirect(w, "/f/"+fragment)
})
}

View File

@ -2,6 +2,7 @@ package forum
import ( import (
"net/http" "net/http"
"regexp"
"strconv" "strconv"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
@ -11,22 +12,24 @@ import (
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
) )
var ThreadPathRegexp = regexp.MustCompile(`^/forum/thread/(\d+)$`)
// Thread view for the comment thread body of a forum post. // Thread view for the comment thread body of a forum post.
func Thread() http.HandlerFunc { func Thread() http.HandlerFunc {
tmpl := templates.Must("forum/thread.html") tmpl := templates.Must("forum/thread.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the path parameters // Parse the path parameters
var ( var (
idStr = r.PathValue("id")
forum *models.Forum forum *models.Forum
thread *models.Thread thread *models.Thread
) )
if idStr == "" { if m := ThreadPathRegexp.FindStringSubmatch(r.URL.Path); m == nil {
log.Error("Regexp failed to parse: %s", r.URL.Path)
templates.NotFoundPage(w, r) templates.NotFoundPage(w, r)
return return
} else { } else {
if threadID, err := strconv.Atoi(idStr); err != nil { if threadID, err := strconv.Atoi(m[1]); err != nil {
session.FlashError(w, r, "Invalid thread ID in the address bar.") session.FlashError(w, r, "Invalid thread ID in the address bar.")
templates.Redirect(w, "/forum") templates.Redirect(w, "/forum")
return return
@ -51,16 +54,12 @@ func Thread() http.HandlerFunc {
return return
} }
// Is it a private forum? // Is it an inner circle forum?
if !forum.CanBeSeenBy(currentUser) { if forum.InnerCircle && !currentUser.IsInnerCircle() {
templates.NotFoundPage(w, r) templates.NotFoundPage(w, r)
return return
} }
// Can we moderate this forum? (from a user-owned forum perspective,
// e.g. can we delete threads and posts, not edit them)
var canModerate = forum.CanBeModeratedBy(currentUser)
// Ping the view count on this thread. // Ping the view count on this thread.
if err := thread.View(currentUser.ID); err != nil { if err := thread.View(currentUser.ID); err != nil {
log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err) log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err)
@ -74,7 +73,7 @@ func Thread() http.HandlerFunc {
} }
pager.ParsePage(r) pager.ParsePage(r)
comments, err := models.PaginateComments(currentUser, "threads", thread.ID, canModerate, pager) comments, err := models.PaginateComments(currentUser, "threads", thread.ID, pager)
if err != nil { if err != nil {
session.FlashError(w, r, "Couldn't paginate comments: %s", err) session.FlashError(w, r, "Couldn't paginate comments: %s", err)
templates.Redirect(w, "/") templates.Redirect(w, "/")
@ -97,23 +96,14 @@ func Thread() http.HandlerFunc {
// Is the current user subscribed to notifications on this thread? // Is the current user subscribed to notifications on this thread?
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID) _, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
// Ping this user as having used the forums today.
go func() {
if err := models.LogDailyForumUser(currentUser); err != nil {
log.Error("LogDailyForumUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
}
}()
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"Forum": forum, "Forum": forum,
"Thread": thread, "Thread": thread,
"Comments": comments, "Comments": comments,
"LikeMap": commentLikeMap, "LikeMap": commentLikeMap,
"PhotoMap": photos, "PhotoMap": photos,
"Pager": pager, "Pager": pager,
"CanModerate": canModerate, "IsSubscribed": isSubscribed,
"IsSubscribed": isSubscribed,
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -10,7 +10,6 @@ import (
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
"code.nonshy.com/nonshy/website/pkg/webpush"
) )
// AddFriend controller to send a friend request. // AddFriend controller to send a friend request.
@ -61,11 +60,6 @@ func AddFriend() http.HandlerFunc {
return return
} }
// Revoke any friends-only photo notifications they had received before.
if err := models.RevokeFriendPhotoNotifications(currentUser, user); err != nil {
log.Error("Couldn't revoke friend photo notifications between %s and %s: %s", currentUser.Username, user.Username, err)
}
var message string var message string
if verdict == "reject" { if verdict == "reject" {
message = fmt.Sprintf("Friend request from %s has been rejected.", username) message = fmt.Sprintf("Friend request from %s has been rejected.", username)
@ -76,12 +70,6 @@ func AddFriend() http.HandlerFunc {
session.Flash(w, r, message) session.Flash(w, r, message)
if verdict == "reject" { if verdict == "reject" {
templates.Redirect(w, "/friends?view=requests") templates.Redirect(w, "/friends?view=requests")
// Log the change.
models.LogDeleted(currentUser, nil, "friends", user.ID, "Rejected friend request from "+user.Username+".", nil)
} else {
// Log the change.
models.LogDeleted(currentUser, nil, "friends", user.ID, "Removed friendship with "+user.Username+".", nil)
} }
templates.Redirect(w, "/friends") templates.Redirect(w, "/friends")
return return
@ -92,9 +80,6 @@ func AddFriend() http.HandlerFunc {
session.Flash(w, r, "You have ignored the friend request from %s.", username) session.Flash(w, r, "You have ignored the friend request from %s.", username)
} }
templates.Redirect(w, "/friends") templates.Redirect(w, "/friends")
// Log the change.
models.LogUpdated(currentUser, nil, "friends", user.ID, "Ignored the friend request from "+user.Username+".", nil)
return return
} else { } else {
// Post the friend request. // Post the friend request.
@ -116,28 +101,7 @@ func AddFriend() http.HandlerFunc {
session.Flash(w, r, "You accepted the friend request from %s!", username) session.Flash(w, r, "You accepted the friend request from %s!", username)
templates.Redirect(w, "/friends?view=requests") templates.Redirect(w, "/friends?view=requests")
// Log the change.
models.LogUpdated(currentUser, nil, "friends", user.ID, "Accepted friend request from "+user.Username+".", nil)
return return
} else {
// Log the change.
models.LogCreated(currentUser, "friends", user.ID, "Sent a friend request to "+user.Username+".")
// Send a push notification to the recipient.
go func() {
// Opted out of this one?
if user.GetProfileField(config.PushNotificationOptOutFriends) == "true" {
return
}
log.Info("Try and send Web Push notification about new Friend Request to: %s", user.Username)
webpush.SendNotification(user, webpush.Payload{
Topic: "friend",
Title: "New Friend Request!",
Body: fmt.Sprintf("%s wants to be your friend on %s.", currentUser.Username, config.Title),
})
}()
} }
session.Flash(w, r, "Friend request sent!") session.Flash(w, r, "Friend request sent!")
} }

View File

@ -1,80 +0,0 @@
package htmx
import (
"net/http"
"net/url"
"time"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/middleware"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// Statistics and social activity on the user's profile page.
func UserProfileActivityCard() http.HandlerFunc {
tmpl := templates.MustLoadCustom("partials/htmx/profile_activity.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
username = r.FormValue("username")
)
if username == "" {
templates.NotFoundPage(w, r)
return
}
// Debug: use ?delay=true to force a slower response.
if r.FormValue("delay") != "" {
time.Sleep(1 * time.Second)
}
// Find this user.
user, err := models.FindUser(username)
if err != nil {
templates.NotFoundPage(w, r)
return
}
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "You must be signed in to view this page.")
templates.Redirect(w, "/login?next=/u/"+url.QueryEscape(r.URL.String()))
return
}
// Is the site under a Maintenance Mode restriction?
if middleware.MaintenanceMode(currentUser, w, r) {
return
}
// Inject relationship booleans for profile picture display.
models.SetUserRelationships(currentUser, []*models.User{user})
// Give a Not Found page if we can not see this user.
if err := user.CanBeSeenBy(currentUser); err != nil {
log.Error("%s can not be seen by viewer %s: %s", user.Username, currentUser.Username, err)
templates.NotFoundPage(w, r)
return
}
vars := map[string]interface{}{
"User": user,
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
"FriendCount": models.CountFriends(user.ID),
"ForumThreadCount": models.CountThreadsByUser(user),
"ForumReplyCount": models.CountCommentsByUser(user, "threads"),
"PhotoCommentCount": models.CountCommentsByUser(user, "photos"),
"CommentsReceivedCount": models.CountCommentsReceived(user),
"LikesGivenCount": models.CountLikesGiven(user),
"LikesReceivedCount": models.CountLikesReceived(user),
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -4,12 +4,9 @@ import (
"fmt" "fmt"
"net/http" "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/models"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
"code.nonshy.com/nonshy/website/pkg/webpush"
) )
// Compose a new chat coming from a user's profile page. // Compose a new chat coming from a user's profile page.
@ -64,25 +61,9 @@ func Compose() http.HandlerFunc {
return return
} }
// Send a push notification to the recipient.
go func() {
// Opted out of this one?
if user.GetProfileField(config.PushNotificationOptOutMessage) == "true" {
return
}
log.Info("Try and send Web Push notification about new Message to: %s", user.Username)
webpush.SendNotification(user, webpush.Payload{
Topic: "inbox",
Title: "New Message!",
Body: fmt.Sprintf("%s has left you a message on %s.", currentUser.Username, config.Title),
})
}()
session.Flash(w, r, "Your message has been delivered!") session.Flash(w, r, "Your message has been delivered!")
if from == "inbox" { if from == "inbox" {
templates.Redirect(w, fmt.Sprintf("/messages/read/%d", m.ID)) templates.Redirect(w, fmt.Sprintf("/messages/read/%d", m.ID))
return
} }
templates.Redirect(w, "/messages") templates.Redirect(w, "/messages")
return return

View File

@ -2,6 +2,7 @@ package inbox
import ( import (
"net/http" "net/http"
"regexp"
"strconv" "strconv"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
@ -10,16 +11,12 @@ import (
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
) )
var ReadURLRegexp = regexp.MustCompile(`^/messages/read/(\d+)$`)
// Inbox is where users receive direct messages. // Inbox is where users receive direct messages.
func Inbox() http.HandlerFunc { func Inbox() http.HandlerFunc {
tmpl := templates.Must("inbox/inbox.html") tmpl := templates.Must("inbox/inbox.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Message ID in path? (/messages/read/{id} endpoint)
var msgId int
if idStr := r.PathValue("id"); idStr != "" {
msgId, _ = strconv.Atoi(idStr)
}
currentUser, err := session.CurrentUser(r) currentUser, err := session.CurrentUser(r)
if err != nil { if err != nil {
session.FlashError(w, r, "Unexpected error: could not get currentUser.") session.FlashError(w, r, "Unexpected error: could not get currentUser.")
@ -38,8 +35,10 @@ func Inbox() http.HandlerFunc {
viewThread []*models.Message viewThread []*models.Message
threadPager *models.Pagination threadPager *models.Pagination
composeToUser *models.User composeToUser *models.User
msgId int
) )
if msgId > 0 { if uri := ReadURLRegexp.FindStringSubmatch(r.URL.Path); uri != nil {
msgId, _ = strconv.Atoi(uri[1])
if msg, err := models.GetMessage(uint64(msgId)); err != nil { if msg, err := models.GetMessage(uint64(msgId)); err != nil {
session.FlashError(w, r, "Message not found.") session.FlashError(w, r, "Message not found.")
templates.Redirect(w, "/messages") templates.Redirect(w, "/messages")

View File

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

View File

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

View File

@ -5,7 +5,6 @@ import (
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models/demographic"
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
) )
@ -19,17 +18,7 @@ func Create() http.HandlerFunc {
return return
} }
// Get website statistics to show on home page. if err := tmpl.Execute(w, r, nil); err != nil {
demo, err := demographic.Get()
if err != nil {
log.Error("demographic.Get: %s", err)
}
vars := map[string]interface{}{
"Demographic": demo,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@ -49,12 +38,3 @@ func Manifest() http.HandlerFunc {
http.ServeFile(w, r, config.StaticPath+"/manifest.json") http.ServeFile(w, r, config.StaticPath+"/manifest.json")
}) })
} }
// Service Worker for web push.
func ServiceWorker() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/javascript; charset=UTF-8")
w.Header().Add("Service-Worker-Allowed", "/")
http.ServeFile(w, r, config.StaticPath+"/js/service-worker.js")
})
}

View File

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

View File

@ -2,16 +2,12 @@ package photo
import ( import (
"bytes" "bytes"
"fmt"
"io" "io"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strconv" "strconv"
"time"
"code.nonshy.com/nonshy/website/pkg/chat"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/encryption/coldstorage"
"code.nonshy.com/nonshy/website/pkg/geoip" "code.nonshy.com/nonshy/website/pkg/geoip"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/mail" "code.nonshy.com/nonshy/website/pkg/mail"
@ -74,8 +70,6 @@ func Certification() http.HandlerFunc {
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
// Are they deleting their photo? // Are they deleting their photo?
if r.PostFormValue("delete") == "true" { if r.PostFormValue("delete") == "true" {
// Primary cert photo
if cert.Filename != "" { if cert.Filename != "" {
if err := photo.Delete(cert.Filename); err != nil { if err := photo.Delete(cert.Filename); err != nil {
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.Filename, err) log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.Filename, err)
@ -83,17 +77,8 @@ func Certification() http.HandlerFunc {
cert.Filename = "" cert.Filename = ""
} }
// Secondary cert photo
if cert.SecondaryFilename != "" {
if err := photo.Delete(cert.SecondaryFilename); err != nil {
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
}
cert.SecondaryFilename = ""
}
cert.Status = models.CertificationPhotoNeeded cert.Status = models.CertificationPhotoNeeded
cert.AdminComment = "" cert.AdminComment = ""
cert.SecondaryVerified = false
cert.Save() cert.Save()
// Removing your photo = not certified again. // Removing your photo = not certified again.
@ -102,22 +87,11 @@ func Certification() http.HandlerFunc {
session.FlashError(w, r, "Error saving your User data: %s", err) session.FlashError(w, r, "Error saving your User data: %s", err)
} }
// Log the change.
models.LogDeleted(currentUser, nil, "certification_photos", currentUser.ID, "Removed their certification photo.", cert)
// Kick them from the chat room if they are online.
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
}
session.Flash(w, r, "Your certification photo has been deleted.") session.Flash(w, r, "Your certification photo has been deleted.")
templates.Redirect(w, r.URL.Path) templates.Redirect(w, r.URL.Path)
return return
} }
// Is it their secondary form of ID being uploaded?
isSecondary := r.PostFormValue("secondary") == "true"
// Get the uploaded file. // Get the uploaded file.
file, header, err := r.FormFile("file") file, header, err := r.FormFile("file")
if err != nil { if err != nil {
@ -141,38 +115,17 @@ func Certification() http.HandlerFunc {
} }
// Are they replacing their old photo? // Are they replacing their old photo?
if cert.Filename != "" && !isSecondary { if cert.Filename != "" {
if err := photo.Delete(cert.Filename); err != nil { if err := photo.Delete(cert.Filename); err != nil {
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.Filename, err) log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.Filename, err)
} }
} else if isSecondary && cert.SecondaryFilename != "" {
if err := photo.Delete(cert.SecondaryFilename); err != nil {
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
}
} }
// Update their certification photo. // Update their certification photo.
cert.Status = models.CertificationPhotoPending cert.Status = models.CertificationPhotoPending
if isSecondary { cert.Filename = filename
cert.SecondaryFilename = filename
cert.SecondaryNeeded = true
cert.SecondaryVerified = false
} else {
cert.Filename = filename
}
cert.AdminComment = "" cert.AdminComment = ""
cert.IPAddress = utility.IPAddress(r) cert.IPAddress = utility.IPAddress(r)
// Secondary ID workflow: if the user
// 1. Uploads a regular cert photo
// 2. An admin marks secondary ID as needed
// 3. They remove everything and reupload a new cert photo, without a secondary ID attached
// Then we don't e-mail the admin for approval yet, and move straight to Secondary ID Requested
// for the user to upload their secondary ID now.
if cert.Status == models.CertificationPhotoPending && cert.SecondaryNeeded && cert.SecondaryFilename == "" {
cert.Status = models.CertificationPhotoSecondary
}
if err := cert.Save(); err != nil { if err := cert.Save(); err != nil {
session.FlashError(w, r, "Error saving your CertificationPhoto: %s", err) session.FlashError(w, r, "Error saving your CertificationPhoto: %s", err)
templates.Redirect(w, r.URL.Path) templates.Redirect(w, r.URL.Path)
@ -185,31 +138,19 @@ func Certification() http.HandlerFunc {
session.FlashError(w, r, "Error saving your User data: %s", err) session.FlashError(w, r, "Error saving your User data: %s", err)
} }
// Kick them from the chat room if they are online. // Notify the admin email to check out this photo.
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil { if err := mail.Send(mail.Message{
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err) To: config.Current.AdminEmail,
Subject: "New Certification Photo Needs Approval",
Template: "email/certification_admin.html",
Data: map[string]interface{}{
"User": currentUser,
"URL": config.Current.BaseURL + "/admin/photo/certification",
},
}); err != nil {
log.Error("Certification: failed to notify admins of pending photo: %s", err)
} }
// Log the change. Note the original IP and GeoIP insights - we once saw a spammer upload
// their cert photo from Nigeria, and before we could reject it, they removed and reuploaded
// it from New York using a VPN. If it wasn't seen in real time, this might have slipped by.
var insights string
if i, err := geoip.GetRequestInsights(r); err == nil {
insights = i.String()
} else {
insights = "error: " + err.Error()
}
models.LogCreated(
currentUser,
"certification_photos",
currentUser.ID,
fmt.Sprintf(
"Uploaded a new certification photo.\n\n* From IP address: %s\n* GeoIP insight: %s",
cert.IPAddress,
insights,
),
)
session.Flash(w, r, "Your certification photo has been uploaded and is now awaiting approval.") session.Flash(w, r, "Your certification photo has been uploaded and is now awaiting approval.")
templates.Redirect(w, r.URL.Path) templates.Redirect(w, r.URL.Path)
return return
@ -343,20 +284,9 @@ func AdminCertification() http.HandlerFunc {
} else { } else {
cert.Status = models.CertificationPhotoRejected cert.Status = models.CertificationPhotoRejected
cert.AdminComment = comment cert.AdminComment = comment
if comment == "(ignore)" || comment == "(secondary)" { if comment == "(ignore)" {
cert.AdminComment = "" cert.AdminComment = ""
} }
// With a secondary photo ID? Remove the photo ID immediately.
if cert.SecondaryFilename != "" {
// Delete it immediately.
if err := photo.Delete(cert.SecondaryFilename); err != nil {
session.FlashError(w, r, "Failed to delete old secondary ID cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
}
cert.SecondaryFilename = ""
cert.SecondaryVerified = false
}
if err := cert.Save(); err != nil { if err := cert.Save(); err != nil {
session.FlashError(w, r, "Failed to save CertificationPhoto: %s", err) session.FlashError(w, r, "Failed to save CertificationPhoto: %s", err)
templates.Redirect(w, r.URL.Path) templates.Redirect(w, r.URL.Path)
@ -367,14 +297,6 @@ func AdminCertification() http.HandlerFunc {
user.Certified = false user.Certified = false
user.Save() user.Save()
// Log the change.
models.LogEvent(user, currentUser, models.ChangeLogRejected, "certification_photos", user.ID, "Rejected the certification photo with comment: "+comment)
// Kick them from the chat room if they are online.
if _, err := chat.MaybeDisconnectUser(user); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
}
// Did we silently ignore it? // Did we silently ignore it?
if comment == "(ignore)" { if comment == "(ignore)" {
session.FlashError(w, r, "The certification photo was ignored with no comment, and will not notify the sender.") session.FlashError(w, r, "The certification photo was ignored with no comment, and will not notify the sender.")
@ -382,46 +304,6 @@ func AdminCertification() http.HandlerFunc {
return return
} }
// Secondary verification required: the user will be asked to upload a blacked-out
// photo ID to be certified again.
if comment == "(secondary)" {
cert.Status = models.CertificationPhotoSecondary
cert.SecondaryNeeded = true
cert.SecondaryVerified = false
if err := cert.Save(); err != nil {
log.Error("Error saving cert photo: %s", err)
}
// Notify the user about this rejection.
notif := &models.Notification{
UserID: user.ID,
AboutUser: *user,
Type: models.NotificationCertSecondary,
Message: "A secondary form of photo ID is requested. Please [click here](/photo/certification) to learn more.",
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create rejection notification: %s", err)
}
// Notify the user via email.
if err := mail.Send(mail.Message{
To: user.Email,
Subject: "Regarding your nonshy certification photo",
Template: "email/certification_secondary.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)
}
session.Flash(w, r, "The user will be asked to provide a secondary form of ID.")
templates.Redirect(w, r.URL.Path)
return
}
// Notify the user about this rejection. // Notify the user about this rejection.
notif := &models.Notification{ notif := &models.Notification{
UserID: user.ID, UserID: user.ID,
@ -434,21 +316,17 @@ func AdminCertification() http.HandlerFunc {
} }
// Notify the user via email. // Notify the user via email.
if err := mail.LockSending("cert_rejected", user.Email, config.EmailDebounceDefault); err == nil { if err := mail.Send(mail.Message{
if err := mail.Send(mail.Message{ To: user.Email,
To: user.Email, Subject: "Your certification photo has been rejected",
Subject: "Your certification photo has been denied", Template: "email/certification_rejected.html",
Template: "email/certification_rejected.html", Data: map[string]interface{}{
Data: map[string]interface{}{ "Username": user.Username,
"Username": user.Username, "AdminComment": comment,
"AdminComment": comment, "URL": config.Current.BaseURL + "/photo/certification",
"URL": config.Current.BaseURL + "/photo/certification", },
}, }); err != nil {
}); err != nil { session.FlashError(w, r, "Note: failed to email user about the rejection: %s", err)
session.FlashError(w, r, "Note: failed to email user about the rejection: %s", err)
}
} else {
log.Error("LockSending: cert_rejected e-mail is not sent to %s: one was sent recently", user.Email)
} }
} }
@ -456,36 +334,6 @@ func AdminCertification() http.HandlerFunc {
case "approve": case "approve":
cert.Status = models.CertificationPhotoApproved cert.Status = models.CertificationPhotoApproved
cert.AdminComment = "" cert.AdminComment = ""
// With a secondary photo ID?
if cert.SecondaryFilename != "" {
// Move the original photo into cold storage.
coldStorageFilename := fmt.Sprintf(
"photoID-%d-%s-%d.jpg",
user.ID,
user.Username,
time.Now().Unix(),
)
if err := coldstorage.FileToColdStorage(
photo.DiskPath(cert.SecondaryFilename),
coldStorageFilename,
config.Current.Encryption.ColdStorageRSAPublicKey,
); err != nil {
session.FlashError(w, r, "Failed to move to cold storage: %s", err)
templates.Redirect(w, r.URL.Path)
return
} else {
session.Flash(w, r, "Note: the secondary photo ID was encrypted to cold storage @ %s", coldStorageFilename)
}
// Delete it immediately.
if err := photo.Delete(cert.SecondaryFilename); err != nil {
session.FlashError(w, r, "Failed to delete old secondary ID cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
}
cert.SecondaryFilename = ""
cert.SecondaryVerified = true
}
if err := cert.Save(); err != nil { if err := cert.Save(); err != nil {
session.FlashError(w, r, "Failed to save CertificationPhoto: %s", err) session.FlashError(w, r, "Failed to save CertificationPhoto: %s", err)
templates.Redirect(w, r.URL.Path) templates.Redirect(w, r.URL.Path)
@ -507,25 +355,18 @@ func AdminCertification() http.HandlerFunc {
} }
// Notify the user via email. // Notify the user via email.
if err := mail.LockSending("cert_approved", user.Email, config.EmailDebounceDefault); err == nil { if err := mail.Send(mail.Message{
if err := mail.Send(mail.Message{ To: user.Email,
To: user.Email, Subject: "Your certification photo has been approved!",
Subject: "Your certification photo has been approved!", Template: "email/certification_approved.html",
Template: "email/certification_approved.html", Data: map[string]interface{}{
Data: map[string]interface{}{ "Username": user.Username,
"Username": user.Username, "URL": config.Current.BaseURL,
"URL": config.Current.BaseURL, },
}, }); err != nil {
}); err != nil { session.FlashError(w, r, "Note: failed to email user about the approval: %s", err)
session.FlashError(w, r, "Note: failed to email user about the approval: %s", err)
}
} else {
log.Error("LockSending: cert_approved e-mail is not sent to %s: one was sent recently", user.Email)
} }
// Log the change.
models.LogEvent(user, currentUser, models.ChangeLogApproved, "certification_photos", user.ID, "Approved the certification photo.")
session.Flash(w, r, "Certification photo approved!") session.Flash(w, r, "Certification photo approved!")
default: default:
session.FlashError(w, r, "Unsupported verdict option: %s", verdict) session.FlashError(w, r, "Unsupported verdict option: %s", verdict)

View File

@ -5,9 +5,7 @@ import (
"net/http" "net/http"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"code.nonshy.com/nonshy/website/pkg/chat"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
@ -44,13 +42,9 @@ func Edit() http.HandlerFunc {
return 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? // Do we have permission for this photo?
if photo.UserID != currentUser.ID { if photo.UserID != currentUser.ID {
if !currentUser.HasAdminScope(config.ScopePhotoModerator) { if !currentUser.IsAdmin {
templates.ForbiddenPage(w, r) templates.ForbiddenPage(w, r)
return return
} }
@ -71,69 +65,32 @@ func Edit() http.HandlerFunc {
// Are we saving the changes? // Are we saving the changes?
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
// Record if this change is going to make them a Shy Account.
var wasShy = currentUser.IsShy()
var ( var (
caption = strings.TrimSpace(r.FormValue("caption")) caption = r.FormValue("caption")
altText = strings.TrimSpace(r.FormValue("alt_text"))
isExplicit = r.FormValue("explicit") == "true" isExplicit = r.FormValue("explicit") == "true"
isGallery = r.FormValue("gallery") == "true" isGallery = r.FormValue("gallery") == "true"
isPinned = r.FormValue("pinned") == "true"
visibility = models.PhotoVisibility(r.FormValue("visibility")) visibility = models.PhotoVisibility(r.FormValue("visibility"))
// Profile pic fields // Profile pic fields
setProfilePic = r.FormValue("intent") == "profile-pic" setProfilePic = r.FormValue("intent") == "profile-pic"
crop = pphoto.ParseCropCoords(r.FormValue("crop")) crop = pphoto.ParseCropCoords(r.FormValue("crop"))
// Are we GOING private? // Re-compute the face score (admin only)
recomputeFaceScore = r.FormValue("recompute_face_score") == "true" && currentUser.IsAdmin
// Are we GOING private or changing to Inner Circle?
goingPrivate = visibility == models.PhotoPrivate && visibility != photo.Visibility goingPrivate = visibility == models.PhotoPrivate && visibility != photo.Visibility
goingCircle = visibility == models.PhotoInnerCircle && 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 {
altText = altText[:config.AltTextMaxLength]
}
// Respect the Site Gallery throttle in case the user is messing around. // Respect the Site Gallery throttle in case the user is messing around.
if SiteGalleryThrottled { if SiteGalleryThrottled {
isGallery = false isGallery = false
} }
// Diff for the ChangeLog.
diffs := []models.FieldDiff{
models.NewFieldDiff("Caption", photo.Caption, caption),
models.NewFieldDiff("Explicit", photo.Explicit, isExplicit),
models.NewFieldDiff("Gallery", photo.Gallery, isGallery),
models.NewFieldDiff("Pinned", photo.Pinned, isPinned),
models.NewFieldDiff("Visibility", photo.Visibility, visibility),
}
// Admin label options.
if requestUser.HasAdminScope(config.ScopePhotoModerator) {
var adminLabel string
if labels, ok := r.PostForm["admin_label"]; ok {
adminLabel = strings.Join(labels, ",")
}
diffs = append(diffs,
models.NewFieldDiff("Admin Label", photo.AdminLabel, adminLabel),
)
photo.AdminLabel = adminLabel
}
// Admin label: forced explicit?
if photo.HasAdminLabelForceExplicit() {
isExplicit = true
}
photo.Caption = caption photo.Caption = caption
photo.AltText = altText
photo.Explicit = isExplicit photo.Explicit = isExplicit
photo.Gallery = isGallery photo.Gallery = isGallery
photo.Pinned = isPinned
photo.Visibility = visibility photo.Visibility = visibility
// Can not use a GIF as profile pic. // Can not use a GIF as profile pic.
@ -162,32 +119,17 @@ func Edit() http.HandlerFunc {
setProfilePic = false setProfilePic = false
} }
// If the user is fighting a recent Explicit flag from the community. log.Error("SAVING PHOTO: %+v", photo)
if isFightingExplicitFlag {
// Notify the admin (unless we are an admin). // Are we re-computing the face score?
if !requestUser.IsAdmin { if recomputeFaceScore {
fb := &models.Feedback{ score, err := pphoto.ComputeFaceScore(pphoto.DiskPath(photo.Filename))
Intent: "report", if err != nil {
Subject: "Explicit photo flag dispute", session.FlashError(w, r, "Face score: %s", err)
UserID: currentUser.ID, } else {
TableName: "photos", session.Flash(w, r, "Face score recomputed!")
TableID: photo.ID, photo.FaceScore = &score
Message: "A user's photo was recently **flagged by the community** as Explicit, and its owner " +
"has **removed** the Explicit setting.\n\n" +
"Please check out the photo below and verify what its Explicit setting should be:",
}
if err := models.CreateFeedback(fb); err != nil {
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
}
} }
// Allow this change but clear the Flagged status.
photo.Flagged = false
// Clear the notification about this.
models.RemoveSpecificNotification(currentUser.ID, models.NotificationExplicitPhoto, "photos", photo.ID)
} }
if err := photo.Save(); err != nil { if err := photo.Save(); err != nil {
@ -206,25 +148,14 @@ func Edit() http.HandlerFunc {
// Flash success. // Flash success.
session.Flash(w, r, "Photo settings updated!") session.Flash(w, r, "Photo settings updated!")
// Log the change.
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 this picture has moved to Private, revoke any notification we gave about it before. // If this picture has moved to Private, revoke any notification we gave about it before.
if goingPrivate { if goingPrivate || goingCircle {
log.Info("The picture is GOING PRIVATE (to %s), revoke any notifications about it", photo.Visibility) log.Info("The picture is GOING PRIVATE (to %s), revoke any notifications about it", photo.Visibility)
models.RemoveNotification("photos", photo.ID) models.RemoveNotification("photos", photo.ID)
} }
// Return the user to their gallery. // Return the user to their gallery.
templates.Redirect(w, "/u/"+currentUser.Username+"/photos") templates.Redirect(w, "/photo/u/"+currentUser.Username)
return return
} }
@ -232,10 +163,6 @@ func Edit() http.HandlerFunc {
"EditPhoto": photo, "EditPhoto": photo,
"SiteGalleryThrottled": SiteGalleryThrottled, "SiteGalleryThrottled": SiteGalleryThrottled,
"SiteGalleryThrottleLimit": config.SiteGalleryRateLimitMax, "SiteGalleryThrottleLimit": config.SiteGalleryRateLimitMax,
// Available admin labels enum.
"RequestUser": requestUser,
"AvailableAdminLabels": config.AdminLabelPhotoOptions,
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {
@ -246,10 +173,109 @@ func Edit() http.HandlerFunc {
} }
// Delete controller (/photo/Delete?id=N) to change properties about your picture. // Delete controller (/photo/Delete?id=N) to change properties about your picture.
//
// DEPRECATED: send them to the batch-edit endpoint.
func Delete() http.HandlerFunc { func Delete() http.HandlerFunc {
// Reuse the upload page but with an EditPhoto variable.
tmpl := templates.Must("photo/delete.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
templates.Redirect(w, fmt.Sprintf("/photo/batch-edit?intent=delete&id=%s", r.FormValue("id"))) // Query params.
photoID, err := strconv.Atoi(r.FormValue("id"))
if err != nil {
log.Error("photo.Delete: failed to parse `id` param (%s) as int: %s", r.FormValue("id"), err)
session.FlashError(w, r, "Photo 'id' parameter required.")
templates.Redirect(w, "/")
return
}
// Page to redirect to in case of errors.
redirect := fmt.Sprintf("%s?id=%d", r.URL.Path, photoID)
// Find this photo by ID.
photo, err := models.GetPhoto(uint64(photoID))
if err != nil {
templates.NotFoundPage(w, r)
return
}
// Load the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
templates.Redirect(w, "/")
return
}
// Do we have permission for this photo?
if photo.UserID != currentUser.ID {
if !currentUser.IsAdmin {
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
}
session.Flash(w, r, "Photo deleted!")
// Return the user to their gallery.
templates.Redirect(w, "/photo/u/"+currentUser.Username)
return
}
var vars = map[string]interface{}{
"Photo": photo,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}) })
} }

View File

@ -1,63 +0,0 @@
package photo
import (
"net/http"
"strconv"
"strings"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// User endpoint to flag other photos as explicit on their behalf.
func MarkPhotoExplicit() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
photoID uint64
next = r.FormValue("next")
)
if !strings.HasPrefix(next, "/") {
next = "/"
}
// Get current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Failed to get current user: %s", err)
templates.Redirect(w, "/")
return
}
if idInt, err := strconv.Atoi(r.FormValue("photo_id")); err == nil {
photoID = uint64(idInt)
} else {
session.FlashError(w, r, "Invalid or missing photo_id parameter: %s", err)
templates.Redirect(w, next)
return
}
// Get this photo.
photo, err := models.GetPhoto(photoID)
if err != nil {
session.FlashError(w, r, "Didn't find photo ID in database: %s", err)
templates.Redirect(w, next)
return
}
photo.Explicit = true
if err := photo.Save(); err != nil {
session.FlashError(w, r, "Couldn't save photo: %s", err)
} else {
session.Flash(w, r, "Marked photo as Explicit!")
}
// Log the change.
models.LogUpdated(&models.User{ID: photo.UserID}, currentUser, "photos", photo.ID, "Marked explicit by admin action.", []models.FieldDiff{
models.NewFieldDiff("Explicit", false, true),
})
templates.Redirect(w, next)
})
}

View File

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

View File

@ -4,7 +4,6 @@ import (
"net/http" "net/http"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
@ -18,9 +17,6 @@ func SiteGallery() http.HandlerFunc {
var sortWhitelist = []string{ var sortWhitelist = []string{
"created_at desc", "created_at desc",
"created_at asc", "created_at asc",
"like_count desc",
"comment_count desc",
"views desc",
} }
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -68,18 +64,13 @@ func SiteGallery() http.HandlerFunc {
// They didn't post a "Whose photos" filter, restore it from their last saved default. // They didn't post a "Whose photos" filter, restore it from their last saved default.
who = currentUser.GetProfileField("site_gallery_default") who = currentUser.GetProfileField("site_gallery_default")
} }
if who != "friends" && who != "everybody" && who != "friends+private" && who != "likes" && who != "uncertified" { if who != "friends" && who != "everybody" && who != "friends+private" {
// Default Who setting should be Friends-only, unless you have no friends. // Default Who setting should be Friends-only, unless you have no friends.
if myFriendCount > 0 { if myFriendCount > 0 {
who = "friends" who = "friends"
} else { } else {
who = "everybody" who = "everybody"
} }
// Admin only who option.
if who == "uncertified" && !currentUser.HasAdminScope(config.ScopePhotoModerator) {
who = "friends"
}
} }
// Store their "Whose photos" filter on their page to default it for next time. // Store their "Whose photos" filter on their page to default it for next time.
@ -103,8 +94,6 @@ func SiteGallery() http.HandlerFunc {
AdminView: adminView, AdminView: adminView,
FriendsOnly: who == "friends", FriendsOnly: who == "friends",
IsShy: isShy || who == "friends+private", IsShy: isShy || who == "friends+private",
MyLikes: who == "likes",
Uncertified: who == "uncertified",
}, pager) }, pager)
// Bulk load the users associated with these photos. // Bulk load the users associated with these photos.
@ -125,13 +114,6 @@ func SiteGallery() http.HandlerFunc {
likeMap := models.MapLikes(currentUser, "photos", photoIDs) likeMap := models.MapLikes(currentUser, "photos", photoIDs)
commentMap := models.MapCommentCounts("photos", photoIDs) commentMap := models.MapCommentCounts("photos", photoIDs)
// Ping this user as having used the forums today.
go func() {
if err := models.LogDailyGalleryUser(currentUser); err != nil {
log.Error("LogDailyGalleryUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
}
}()
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"IsSiteGallery": true, "IsSiteGallery": true,
"Photos": photos, "Photos": photos,

View File

@ -2,12 +2,10 @@ package photo
import ( import (
"bytes" "bytes"
"fmt"
"io" "io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
@ -61,12 +59,10 @@ func Upload() http.HandlerFunc {
// Are they POSTing? // Are they POSTing?
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
var ( var (
caption = strings.TrimSpace(r.PostFormValue("caption")) caption = r.PostFormValue("caption")
altText = strings.TrimSpace(r.PostFormValue("alt_text"))
isExplicit = r.PostFormValue("explicit") == "true" isExplicit = r.PostFormValue("explicit") == "true"
visibility = r.PostFormValue("visibility") visibility = r.PostFormValue("visibility")
isGallery = r.PostFormValue("gallery") == "true" isGallery = r.PostFormValue("gallery") == "true"
isPinned = r.PostFormValue("pinned") == "true"
cropCoords = r.PostFormValue("crop") cropCoords = r.PostFormValue("crop")
confirm1 = r.PostFormValue("confirm1") == "true" confirm1 = r.PostFormValue("confirm1") == "true"
confirm2 = r.PostFormValue("confirm2") == "true" confirm2 = r.PostFormValue("confirm2") == "true"
@ -77,14 +73,10 @@ func Upload() http.HandlerFunc {
isGallery = false isGallery = false
} }
if len(altText) > config.AltTextMaxLength {
altText = altText[:config.AltTextMaxLength]
}
// Are they at quota already? // Are they at quota already?
if photoCount >= photoQuota { if photoCount >= photoQuota {
session.FlashError(w, r, "You have too many photos to upload a new one. Please delete a photo to make room for a new one.") session.FlashError(w, r, "You have too many photos to upload a new one. Please delete a photo to make room for a new one.")
templates.Redirect(w, "/u/"+user.Username+"/photos") templates.Redirect(w, "/photo/u/"+user.Username)
return return
} }
@ -142,10 +134,8 @@ func Upload() http.HandlerFunc {
Filename: filename, Filename: filename,
CroppedFilename: cropFilename, CroppedFilename: cropFilename,
Caption: caption, Caption: caption,
AltText: altText,
Visibility: models.PhotoVisibility(visibility), Visibility: models.PhotoVisibility(visibility),
Gallery: isGallery, Gallery: isGallery,
Pinned: isPinned,
Explicit: isExplicit, Explicit: isExplicit,
} }
@ -169,24 +159,11 @@ func Upload() http.HandlerFunc {
user.Save() user.Save()
} }
// ChangeLog entry.
models.LogCreated(user, "photos", p.ID, fmt.Sprintf(
"Uploaded a new photo.\n\n"+
"* Caption: %s\n"+
"* Visibility: %s\n"+
"* Gallery: %v\n"+
"* Explicit: %v",
p.Caption,
p.Visibility,
p.Gallery,
p.Explicit,
))
// Notify all of our friends that we posted a new picture. // Notify all of our friends that we posted a new picture.
go notifyFriendsNewPhoto(p, user) go notifyFriendsNewPhoto(p, user)
session.Flash(w, r, "Your photo has been uploaded successfully.") session.Flash(w, r, "Your photo has been uploaded successfully.")
templates.Redirect(w, "/u/"+user.Username+"/photos") templates.Redirect(w, "/photo/u/"+user.Username)
return return
} }
@ -222,6 +199,15 @@ func notifyFriendsNewPhoto(photo *models.Photo, currentUser *models.User) {
notifyUserIDs = models.PrivateGranteeUserIDs(currentUser.ID) notifyUserIDs = models.PrivateGranteeUserIDs(currentUser.ID)
log.Info("Notify %d private grantees about the new photo by %s", len(notifyUserIDs), currentUser.Username) log.Info("Notify %d private grantees about the new photo by %s", len(notifyUserIDs), currentUser.Username)
} }
} else if photo.Visibility == models.PhotoInnerCircle {
// Inner circle members. If the pic is also Explicit, further narrow to explicit friend IDs.
if photo.Explicit {
notifyUserIDs = models.FriendIDsInCircleAreExplicit(currentUser.ID)
log.Info("Notify %d EXPLICIT circle friends about the new photo by %s", len(notifyUserIDs), currentUser.Username)
} else {
notifyUserIDs = models.FriendIDsInCircle(currentUser.ID)
log.Info("Notify %d circle friends about the new photo by %s", len(notifyUserIDs), currentUser.Username)
}
} else { } else {
// Friends only: we will notify exactly the friends we selected above. // Friends only: we will notify exactly the friends we selected above.
notifyUserIDs = friendIDs notifyUserIDs = friendIDs

View File

@ -2,6 +2,7 @@ package photo
import ( import (
"net/http" "net/http"
"regexp"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
@ -10,24 +11,21 @@ import (
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
) )
var UserPhotosRegexp = regexp.MustCompile(`^/photo/u/([^@]+?)$`)
// UserPhotos controller (/photo/u/:username) to view a user's gallery or manage if it's yourself. // UserPhotos controller (/photo/u/:username) to view a user's gallery or manage if it's yourself.
func UserPhotos() http.HandlerFunc { func UserPhotos() http.HandlerFunc {
tmpl := templates.Must("photo/gallery.html") tmpl := templates.Must("photo/gallery.html")
// Whitelist for ordering options. // Whitelist for ordering options.
var sortWhitelist = []string{ var sortWhitelist = []string{
"pinned desc nulls last, updated_at desc",
"created_at desc", "created_at desc",
"created_at asc", "created_at asc",
"like_count desc",
"comment_count desc",
"views desc",
} }
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query params. // Query params.
var ( var (
username = r.PathValue("username")
viewStyle = r.FormValue("view") // cards (default), full viewStyle = r.FormValue("view") // cards (default), full
// Search filters. // Search filters.
@ -35,6 +33,9 @@ func UserPhotos() http.HandlerFunc {
filterVisibility = r.FormValue("visibility") filterVisibility = r.FormValue("visibility")
sort = r.FormValue("sort") sort = r.FormValue("sort")
sortOK bool sortOK bool
// Inner circle invite view?
innerCircleInvite = r.FormValue("intent") == "inner_circle"
) )
// Sort options. // Sort options.
@ -53,6 +54,13 @@ func UserPhotos() http.HandlerFunc {
viewStyle = "cards" viewStyle = "cards"
} }
// Parse the username out of the URL parameters.
var username string
m := UserPhotosRegexp.FindStringSubmatch(r.URL.Path)
if m != nil {
username = m[1]
}
// Find this user. // Find this user.
user, err := models.FindUser(username) user, err := models.FindUser(username)
if err != nil { if err != nil {
@ -73,6 +81,11 @@ func UserPhotos() http.HandlerFunc {
isShyFrom = !isOwnPhotos && (currentUser.IsShyFrom(user) || (isShy && !areFriends)) isShyFrom = !isOwnPhotos && (currentUser.IsShyFrom(user) || (isShy && !areFriends))
) )
// Inner circle invite: not if we are not in the circle ourselves.
if innerCircleInvite && !currentUser.IsInnerCircle() {
innerCircleInvite = false
}
// Bail early if we are shy from this user. // Bail early if we are shy from this user.
if isShy && isShyFrom { if isShy && isShyFrom {
var vars = map[string]interface{}{ var vars = map[string]interface{}{
@ -121,6 +134,11 @@ func UserPhotos() http.HandlerFunc {
visibility = append(visibility, models.PhotoFriends) visibility = append(visibility, models.PhotoFriends)
} }
// Inner circle photos.
if currentUser.IsInnerCircle() {
visibility = append(visibility, models.PhotoInnerCircle)
}
// If we are Filtering by Visibility, ensure the target visibility is accessible to us. // If we are Filtering by Visibility, ensure the target visibility is accessible to us.
if filterVisibility != "" { if filterVisibility != "" {
var isOK bool var isOK bool
@ -185,28 +203,13 @@ func UserPhotos() http.HandlerFunc {
profilePictureHidden = visibility profilePictureHidden = visibility
} }
// Friend Photos Notification Opt-out:
// If your friend posts too many photos and you want to mute them.
// NOTE: notifications are "on by default" and only an explicit "false"
// stored in the database indicates an opt-out.
// New photo upload notification subscription status.
var areNotificationsMuted bool
if exists, v := models.IsSubscribed(currentUser, "friend.photos", user.ID); exists {
areNotificationsMuted = !v
}
// Should the current user be able to share their private photos with the target?
showPrivateUnlockPrompt, _ := models.ShouldShowPrivateUnlockPrompt(currentUser, user)
var vars = map[string]interface{}{ var vars = map[string]interface{}{
"IsOwnPhotos": currentUser.ID == user.ID, "IsOwnPhotos": currentUser.ID == user.ID,
"IsShyUser": isShy, "IsShyUser": isShy,
"IsShyFrom": isShyFrom, "IsShyFrom": isShyFrom,
"IsMyPrivateUnlockedFor": isGranted, // have WE granted THIS USER to see our private pics? "IsMyPrivateUnlockedFor": isGranted, // have WE granted THIS USER to see our private pics?
"AreWeGrantedPrivate": isGrantee, // have THEY granted US private photo access. "AreWeGrantedPrivate": isGrantee, // have THEY granted US private photo access.
"ShowPrivateUnlockPrompt": showPrivateUnlockPrompt,
"AreFriends": areFriends, "AreFriends": areFriends,
"AreNotificationsMuted": areNotificationsMuted,
"ProfilePictureHiddenVisibility": profilePictureHidden, "ProfilePictureHiddenVisibility": profilePictureHidden,
"User": user, "User": user,
"Photos": photos, "Photos": photos,
@ -214,11 +217,13 @@ func UserPhotos() http.HandlerFunc {
"NoteCount": models.CountNotesAboutUser(currentUser, user), "NoteCount": models.CountNotesAboutUser(currentUser, user),
"FriendCount": models.CountFriends(user.ID), "FriendCount": models.CountFriends(user.ID),
"PublicPhotoCount": models.CountPublicPhotos(user.ID), "PublicPhotoCount": models.CountPublicPhotos(user.ID),
"InnerCircleMinimumPublicPhotos": config.InnerCircleMinimumPublicPhotos,
"Pager": pager, "Pager": pager,
"LikeMap": likeMap, "LikeMap": likeMap,
"CommentMap": commentMap, "CommentMap": commentMap,
"ViewStyle": viewStyle, "ViewStyle": viewStyle,
"ExplicitCount": explicitCount, "ExplicitCount": explicitCount,
"InnerCircleInviteView": innerCircleInvite,
// Search filters // Search filters
"Sort": sort, "Sort": sort,

View File

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

View File

@ -1,150 +0,0 @@
package coldstorage
import (
"crypto/rand"
"crypto/rsa"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"code.nonshy.com/nonshy/website/pkg/encryption/keygen"
"code.nonshy.com/nonshy/website/pkg/log"
)
var (
ColdStorageDirectory = "./coldstorage"
ColdStorageKeysDirectory = path.Join(ColdStorageDirectory, "keys")
ColdStoragePrivateKeyFile = path.Join(ColdStorageKeysDirectory, "private.pem")
ColdStoragePublicKeyFile = path.Join(ColdStorageKeysDirectory, "public.pem")
)
// Initialize generates the RSA key pairs for the first time and creates
// the cold storage directories. It writes the keys to disk and returns the x509 encoded
// public key which goes in the settings.json (the keys on disk are for your bookkeeping).
func Initialize() ([]byte, error) {
log.Warn("NOTICE: rolling a random RSA key pair for cold storage")
rsaKey, err := keygen.NewRSAKeys()
if err != nil {
return nil, fmt.Errorf("generate RSA key: %s", err)
}
// Encode to x509
x509, err := keygen.SerializePublicKey(rsaKey.Public())
if err != nil {
return nil, fmt.Errorf("encode RSA public key to x509: %s", err)
}
// Write the public/private key files to disk.
if _, err := os.Stat(ColdStorageKeysDirectory); os.IsNotExist(err) {
log.Info("Notice: creating cold storage directory")
if err := os.MkdirAll(ColdStorageKeysDirectory, 0755); err != nil {
return nil, fmt.Errorf("create %s: %s", ColdStorageKeysDirectory, err)
}
}
if err := keygen.WriteRSAKeys(
rsaKey,
path.Join(ColdStorageKeysDirectory, "private.pem"),
path.Join(ColdStorageKeysDirectory, "public.pem"),
); err != nil {
return nil, fmt.Errorf("export newly generated public/private key files: %s", err)
}
return x509, nil
}
// Warning returns an error message if the private key is still on disk at its
// original generated location: it should be moved offline for security.
func Warning() error {
if _, err := os.Stat(ColdStoragePrivateKeyFile); os.IsNotExist(err) {
return nil
}
return errors.New("the private key file at ./coldstorage/keys should be moved off of the server and kept offline for safety")
}
// FileToColdStorage will copy a file, encrypted, into cold storage at the given filename.
func FileToColdStorage(sourceFilePath, outputFileName string, publicKeyPEM []byte) error {
if len(publicKeyPEM) == 0 {
return errors.New("no RSA public key")
}
// Load the public key from PEM encoding.
publicKey, err := keygen.DeserializePublicKey(publicKeyPEM)
if err != nil {
return fmt.Errorf("deserializing public key: %s", err)
}
// Generate a unique AES key for encrypting this file in one direction.
aesKey, err := keygen.NewAESKey()
if err != nil {
return err
}
// Encrypt the AES key and store it on disk next to the cold storage file.
ciphertext, err := rsa.EncryptPKCS1v15(rand.Reader, publicKey, aesKey)
if err != nil {
return fmt.Errorf("encrypt error: %s", err)
}
err = os.WriteFile(
filepath.Join(ColdStorageDirectory, outputFileName+".aes"),
ciphertext,
0600,
)
if err != nil {
return err
}
// Read the original plaintext file going into cold storage.
plaintext, err := os.ReadFile(sourceFilePath)
if err != nil {
return fmt.Errorf("source file: %s", err)
}
// Encrypt the plaintext with the AES key.
ciphertext, err = keygen.EncryptWithAESKey(plaintext, aesKey)
if err != nil {
return err
}
// Write it to disk.
return os.WriteFile(filepath.Join(ColdStorageDirectory, outputFileName+".enc"), ciphertext, 0600)
}
// FileFromColdStorage decrypts a cold storage file and writes it to the output file.
//
// The command `nonshy coldstorage decrypt` uses this function. Requirements:
//
// - privateKeyFile is the RSA private key originally generated for cold storage
// - aesKeyFile is the unique .aes file for the cold storage item
// - ciphertextFile is the encrypted cold storage item
// - outputFile is where you want to save the result to
func FileFromColdStorage(privateKeyFile, aesKeyFile, ciphertextFile, outputFile string) error {
privateKey, err := keygen.PrivateKeyFromFile(privateKeyFile)
if err != nil {
return fmt.Errorf("private key file: %s", err)
}
encryptedAESKey, err := os.ReadFile(aesKeyFile)
if err != nil {
return fmt.Errorf("reading aes key file: %s", err)
}
aesKey, err := rsa.DecryptPKCS1v15(rand.Reader, privateKey, encryptedAESKey)
if err != nil {
return fmt.Errorf("decrypting the aes key file: %s", err)
}
ciphertext, err := os.ReadFile(ciphertextFile)
if err != nil {
return fmt.Errorf("reading cold storage file: %s", err)
}
plaintext, err := keygen.DecryptWithAESKey(ciphertext, aesKey)
if err != nil {
return fmt.Errorf("decrypting cold storage file: %s", err)
}
return os.WriteFile(outputFile, plaintext, 0644)
}

View File

@ -7,12 +7,15 @@
package encryption package encryption
import ( import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256" "crypto/sha256"
"errors" "errors"
"fmt" "fmt"
"io"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/encryption/keygen"
) )
// Encrypt a byte stream using the site's AES passphrase. // Encrypt a byte stream using the site's AES passphrase.
@ -21,7 +24,32 @@ func Encrypt(input []byte) ([]byte, error) {
return nil, errors.New("AES key not configured") return nil, errors.New("AES key not configured")
} }
return keygen.EncryptWithAESKey(input, config.Current.Encryption.AESKey) // Generate a new AES cipher.
c, err := aes.NewCipher(config.Current.Encryption.AESKey)
if err != nil {
return nil, err
}
// gcm or Galois/Counter Mode
gcm, err := cipher.NewGCM(c)
if err != nil {
return nil, err
}
// Create a new byte array the size of the GCM nonce
// which must be passed to Seal.
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("populating the nonce: %s", err)
}
// Encrypt the text using the Seal function.
// Seal encrypts and authenticates plaintext, authenticates the
// additional data and appends the result to dst, returning the
// updated slice. The nonce must be NonceSize() bytes long and
// unique for all time, for a given key.
result := gcm.Seal(nonce, nonce, input, nil)
return result, nil
} }
// EncryptString encrypts a string value and returns the cipher text. // EncryptString encrypts a string value and returns the cipher text.
@ -35,7 +63,27 @@ func Decrypt(data []byte) ([]byte, error) {
return nil, errors.New("AES key not configured") return nil, errors.New("AES key not configured")
} }
return keygen.DecryptWithAESKey(data, config.Current.Encryption.AESKey) c, err := aes.NewCipher(config.Current.Encryption.AESKey)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(c)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return nil, errors.New("ciphertext data less than nonceSize")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
} }
// DecryptString decrypts a string value from ciphertext. // DecryptString decrypts a string value from ciphertext.

View File

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

View File

@ -1,72 +0,0 @@
// Package keygen provides the AES key initializer function.
package keygen
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
)
// NewAESKey returns a 32-byte (AES 256 bit) encryption key.
func NewAESKey() ([]byte, error) {
var result = make([]byte, 32)
_, err := rand.Read(result)
return result, err
}
// EncryptWithAESKey a byte stream using a given AES key.
func EncryptWithAESKey(input []byte, key []byte) ([]byte, error) {
// Generate a new AES cipher.
c, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// gcm or Galois/Counter Mode
gcm, err := cipher.NewGCM(c)
if err != nil {
return nil, err
}
// Create a new byte array the size of the GCM nonce
// which must be passed to Seal.
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("populating the nonce: %s", err)
}
// Encrypt the text using the Seal function.
// Seal encrypts and authenticates plaintext, authenticates the
// additional data and appends the result to dst, returning the
// updated slice. The nonce must be NonceSize() bytes long and
// unique for all time, for a given key.
result := gcm.Seal(nonce, nonce, input, nil)
return result, nil
}
func DecryptWithAESKey(data []byte, key []byte) ([]byte, error) {
c, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(c)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return nil, errors.New("ciphertext data less than nonceSize")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}

View File

@ -1,72 +0,0 @@
package keygen_test
import (
"testing"
"code.nonshy.com/nonshy/website/pkg/encryption/keygen"
)
func TestAES(t *testing.T) {
type testCase struct {
AESKey []byte // AES key, nil = generate a new one
Input []byte // input text to encrypt
Encrypted []byte // already encrypted text
Expect []byte // expected output on decrypt
}
var tests = []testCase{
{
Input: []byte("hello world"),
Expect: []byte("hello world"),
},
{
AESKey: []byte{170, 94, 243, 132, 85, 247, 149, 238, 245, 39, 140, 125, 226, 178, 134, 161, 17, 151, 139, 248, 16, 94, 165, 8, 102, 238, 214, 183, 86, 138, 219, 52},
Encrypted: []byte{146, 217, 250, 254, 70, 201, 27, 221, 92, 145, 77, 213, 211, 197, 63, 189, 220, 188, 78, 8, 217, 108, 136, 89, 156, 23, 179, 54, 209, 54, 244, 170, 182, 150, 242, 52, 112, 191, 216, 46},
Expect: []byte("goodbye mars"),
},
}
for i, test := range tests {
if len(test.AESKey) == 0 {
key, err := keygen.NewAESKey()
if err != nil {
t.Errorf("Test #%d: failed to generate new AES key: %s", i, err)
continue
}
test.AESKey = key
}
if len(test.Encrypted) == 0 {
enc, err := keygen.EncryptWithAESKey(test.Input, test.AESKey)
if err != nil {
t.Errorf("Test #%d: failed to encrypt input: %s", i, err)
continue
}
test.Encrypted = enc
}
// t.Errorf("Key: %+v\nEnc: %+v", test.AESKey, test.Encrypted)
dec, err := keygen.DecryptWithAESKey(test.Encrypted, test.AESKey)
if err != nil {
t.Errorf("Test #%d: failed to decrypt: %s", i, err)
continue
}
// compare the results
var ok = true
if len(dec) != len(test.Expect) {
ok = false
} else {
for j := range dec {
if test.Expect[j] != dec[j] {
ok = false
}
}
}
if !ok {
t.Errorf("Test #%d: got unexpected result from decrypt. Expected %s, got %s", i, test.Expect, dec)
continue
}
}
}

View File

@ -0,0 +1,11 @@
// Package keygen provides the AES key initializer function.
package keygen
import "crypto/rand"
// NewAESKey returns a 32-byte (AES 256 bit) encryption key.
func NewAESKey() ([]byte, error) {
var result = make([]byte, 32)
_, err := rand.Read(result)
return result, err
}

View File

@ -1,99 +0,0 @@
// Package keygen provides the AES key initializer function.
package keygen
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"os"
"code.nonshy.com/nonshy/website/pkg/log"
)
// NewRSAKeys will generate an RSA 2048-bit key pair.
func NewRSAKeys() (*rsa.PrivateKey, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
return privateKey, err
}
// SerializePublicKey converts an RSA public key into an x509 PEM encoded byte string.
func SerializePublicKey(publicKey crypto.PublicKey) ([]byte, error) {
// Encode the public key to PEM format.
x509EncodedPub, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
return nil, err
}
pemEncodedPub := pem.EncodeToMemory(&pem.Block{
Type: "RSA PUBLIC KEY",
Bytes: x509EncodedPub,
})
return pemEncodedPub, nil
}
// DeserializePublicKey loads the RSA public key from the PEM encoded byte array.
func DeserializePublicKey(pemEncodedPub []byte) (*rsa.PublicKey, error) {
// Decode the public key.
log.Error("decode public key: %s", pemEncodedPub)
blockPub, _ := pem.Decode(pemEncodedPub)
x509EncodedPub := blockPub.Bytes
genericPublicKey, err := x509.ParsePKIXPublicKey(x509EncodedPub)
if err != nil {
return nil, err
}
publicKey := genericPublicKey.(*rsa.PublicKey)
return publicKey, nil
}
// WriteRSAKeys writes the public and private RSA keys to .pem files on disk.
func WriteRSAKeys(key *rsa.PrivateKey, privateFile, publicFile string) error {
// Encode the private key to PEM format.
x509Encoded := x509.MarshalPKCS1PrivateKey(key)
pemEncoded := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509Encoded,
})
// Encode the public key to PEM format.
pemEncodedPub, err := SerializePublicKey(key.Public())
if err != nil {
return err
}
// Write the files.
if err := os.WriteFile(privateFile, pemEncoded, 0600); err != nil {
return err
}
if err := os.WriteFile(publicFile, pemEncodedPub, 0644); err != nil {
return err
}
return nil
}
// PrivateKeyFromFile loads the private key from disk.
func PrivateKeyFromFile(privateFile string) (*rsa.PrivateKey, error) {
// Read the private key file.
pemEncoded, err := os.ReadFile(privateFile)
if err != nil {
return nil, err
}
// Decode the private key.
block, _ := pem.Decode(pemEncoded)
x509Encoded := block.Bytes
privateKey, _ := x509.ParsePKCS1PrivateKey(x509Encoded)
return privateKey, nil
}
// PublicKeyFromFile loads the public key from disk.
func PublicKeyFromFile(publicFile string) (*rsa.PublicKey, error) {
pemEncodedPub, err := os.ReadFile(publicFile)
if err != nil {
return nil, err
}
// Decode the public key.
return DeserializePublicKey(pemEncodedPub)
}

View File

@ -7,12 +7,9 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"strings" "strings"
"time"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/encryption"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/redis"
"github.com/microcosm-cc/bluemonday" "github.com/microcosm-cc/bluemonday"
"gopkg.in/gomail.v2" "gopkg.in/gomail.v2"
) )
@ -26,22 +23,6 @@ type Message struct {
Data map[string]interface{} Data map[string]interface{}
} }
// LockSending emails to the same address within 24 hours, e.g.: on the signup form to reduce chance for spam abuse.
//
// Call this before calling Send() if you want to throttle the sending. This function will put a key in Redis on
// the first call and return nil; on subsequent calls, if the key still remains, it will return an error.
func LockSending(namespace, email string, expires time.Duration) error {
var key = fmt.Sprintf("mail/lock-sending/%s/%s", namespace, encryption.Hash([]byte(email)))
// See if we have already locked it.
if redis.Exists(key) {
return errors.New("email was in the lock-sending queue")
}
redis.Set(key, email, expires)
return nil
}
// Send an email. // Send an email.
func Send(msg Message) error { func Send(msg Message) error {
conf := config.Current.Mail conf := config.Current.Mail

View File

@ -22,6 +22,7 @@ func AgeGate(user *models.User, w http.ResponseWriter, r *http.Request) (handled
"/photo/certification", "/photo/certification",
"/photo/private", "/photo/private",
"/photo/view", "/photo/view",
"/photo/u/",
"/comments", "/comments",
"/users/blocked", "/users/blocked",
"/users/block", "/users/block",

View File

@ -46,19 +46,13 @@ func LoginRequired(handler http.Handler) http.Handler {
} }
// Ping LastLoginAt for long lived sessions, but not if impersonated. // Ping LastLoginAt for long lived sessions, but not if impersonated.
var pingLastLoginAt bool
if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown && !session.Impersonated(r) { if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown && !session.Impersonated(r) {
pingLastLoginAt = true user.LastLoginAt = time.Now()
if err := user.PingLastLoginAt(); err != nil { if err := user.Save(); err != nil {
log.Error("LoginRequired: couldn't refresh LastLoginAt for user %s: %s", user.Username, err) log.Error("LoginRequired: couldn't refresh LastLoginAt for user %s: %s", user.Username, err)
} }
} }
// Log the last visit of their current IP address.
if err := models.PingIPAddress(r, user, pingLastLoginAt); err != nil {
log.Error("LoginRequired: couldn't ping user %s IP address: %s", user.Username, err)
}
// Ask the user for their birthdate? // Ask the user for their birthdate?
if AgeGate(user, w, r) { if AgeGate(user, w, r) {
return return
@ -121,11 +115,6 @@ func CertRequired(handler http.Handler) http.Handler {
return return
} }
// Log the last visit of their current IP address.
if err := models.PingIPAddress(r, currentUser, false); err != nil {
log.Error("CertRequired: couldn't ping user %s IP address: %s", currentUser.Username, err)
}
// Are they banned? // Are they banned?
if currentUser.Status == models.UserStatusBanned { if currentUser.Status == models.UserStatusBanned {
session.LogoutUser(w, r) session.LogoutUser(w, r)

View File

@ -3,7 +3,6 @@ package middleware
import ( import (
"context" "context"
"net/http" "net/http"
"time"
"code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
@ -19,9 +18,6 @@ func CSRF(handler http.Handler) http.Handler {
token := MakeCSRFCookie(r, w) token := MakeCSRFCookie(r, w)
ctx := context.WithValue(r.Context(), session.CSRFKey, token) ctx := context.WithValue(r.Context(), session.CSRFKey, token)
// Store the request start time.
ctx = context.WithValue(ctx, session.RequestTimeKey, time.Now())
// If it's a JSON post, allow it thru. // If it's a JSON post, allow it thru.
if r.Header.Get("Content-Type") == "application/json" { if r.Header.Get("Content-Type") == "application/json" {
handler.ServeHTTP(w, r.WithContext(ctx)) handler.ServeHTTP(w, r.WithContext(ctx))

View File

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

View File

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

View File

@ -1,7 +1,6 @@
package models package models
import ( import (
"errors"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@ -9,18 +8,15 @@ import (
// CertificationPhoto table. // CertificationPhoto table.
type CertificationPhoto struct { type CertificationPhoto struct {
ID uint64 `gorm:"primaryKey"` ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"uniqueIndex"` UserID uint64 `gorm:"uniqueIndex"`
Filename string Filename string
Filesize int64 Filesize int64
Status CertificationPhotoStatus Status CertificationPhotoStatus
AdminComment string AdminComment string
SecondaryNeeded bool // a secondary form of ID has been requested IPAddress string // the IP they uploaded the photo from
SecondaryFilename string // photo ID upload CreatedAt time.Time
SecondaryVerified bool // mark true when ID checked so original can be deleted UpdatedAt time.Time
IPAddress string // the IP they uploaded the photo from
CreatedAt time.Time
UpdatedAt time.Time
} }
type CertificationPhotoStatus string type CertificationPhotoStatus string
@ -30,10 +26,6 @@ const (
CertificationPhotoPending CertificationPhotoStatus = "pending" CertificationPhotoPending CertificationPhotoStatus = "pending"
CertificationPhotoApproved CertificationPhotoStatus = "approved" CertificationPhotoApproved CertificationPhotoStatus = "approved"
CertificationPhotoRejected CertificationPhotoStatus = "rejected" CertificationPhotoRejected CertificationPhotoStatus = "rejected"
// If a photo is pending approval but the admin wants to engage the
// secondary check (prompt user for a photo ID upload)
CertificationPhotoSecondary CertificationPhotoStatus = "secondary"
) )
// GetCertificationPhoto retrieves the user's record from the DB or upserts their initial record. // GetCertificationPhoto retrieves the user's record from the DB or upserts their initial record.
@ -51,28 +43,6 @@ func GetCertificationPhoto(userID uint64) (*CertificationPhoto, error) {
return p, result.Error return p, result.Error
} }
// CertifiedSince retrieve's the last updated date of the user's certification photo, if approved.
//
// This incurs a DB query for their cert photo.
func (u *User) CertifiedSince() (time.Time, error) {
if !u.Certified {
return time.Time{}, errors.New("user is not certified")
}
cert, err := GetCertificationPhoto(u.ID)
if err != nil {
return time.Time{}, err
}
if cert.Status != CertificationPhotoApproved {
// The edge case can come up if a user was manually certified but didn't have an approved picture.
// Return their CreatedAt instead.
return u.CreatedAt, nil
}
return cert.UpdatedAt, nil
}
// CertificationPhotosNeedingApproval returns a pager of the pictures that require admin approval. // CertificationPhotosNeedingApproval returns a pager of the pictures that require admin approval.
func CertificationPhotosNeedingApproval(status CertificationPhotoStatus, pager *Pagination) ([]*CertificationPhoto, error) { func CertificationPhotosNeedingApproval(status CertificationPhotoStatus, pager *Pagination) ([]*CertificationPhoto, error) {
var p = []*CertificationPhoto{} var p = []*CertificationPhoto{}

View File

@ -1,254 +0,0 @@
package models
import (
"bytes"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/log"
)
// ChangeLog table to track updates to the database.
type ChangeLog struct {
ID uint64 `gorm:"primaryKey"`
AboutUserID uint64 `gorm:"index"`
AdminUserID uint64 `gorm:"index"` // if an admin edits a user's item
TableName string `gorm:"index"`
TableID uint64 `gorm:"index"`
Event string `gorm:"index"`
Message string
CreatedAt time.Time
UpdatedAt time.Time
}
// Types of ChangeLog events.
const (
ChangeLogCreated = "created"
ChangeLogUpdated = "updated"
ChangeLogDeleted = "deleted"
// Certification photos.
ChangeLogApproved = "approved"
ChangeLogRejected = "rejected"
// Account status updates for easier filtering.
ChangeLogBanned = "banned"
ChangeLogAdmin = "admin" // admin status toggle
ChangeLogLifecycle = "lifecycle" // de/reactivated accounts
)
var ChangeLogEventTypes = []string{
ChangeLogCreated,
ChangeLogUpdated,
ChangeLogDeleted,
ChangeLogApproved,
ChangeLogRejected,
ChangeLogBanned,
ChangeLogAdmin,
ChangeLogLifecycle,
}
// PaginateChangeLog lists the change logs.
func PaginateChangeLog(tableName string, tableID, aboutUserID, adminUserID uint64, event string, search *Search, pager *Pagination) ([]*ChangeLog, error) {
var (
cl = []*ChangeLog{}
where = []string{}
placeholders = []interface{}{}
)
if tableName != "" {
where = append(where, "table_name = ?")
placeholders = append(placeholders, tableName)
}
if tableID != 0 {
where = append(where, "table_id = ?")
placeholders = append(placeholders, tableID)
}
if aboutUserID != 0 {
where = append(where, "about_user_id = ?")
placeholders = append(placeholders, aboutUserID)
}
if adminUserID != 0 {
where = append(where, "admin_user_id = ?")
placeholders = append(placeholders, adminUserID)
}
if event != "" {
where = append(where, "event = ?")
placeholders = append(placeholders, event)
}
// Text search terms
for _, term := range search.Includes {
var ilike = "%" + strings.ToLower(term) + "%"
where = append(where, "change_logs.message ILIKE ?")
placeholders = append(placeholders, ilike)
}
for _, term := range search.Excludes {
var ilike = "%" + strings.ToLower(term) + "%"
where = append(where, "change_logs.message NOT ILIKE ?")
placeholders = append(placeholders, ilike)
}
query := DB.Model(&ChangeLog{}).Where(
strings.Join(where, " AND "),
placeholders...,
).Order(
pager.Sort,
)
query.Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&cl)
return cl, result.Error
}
// ChangeLogTables returns all the distinct table_names appearing in the change log.
func ChangeLogTables() []string {
var result = []string{}
query := DB.Model(&ChangeLog{}).
Select("DISTINCT change_logs.table_name").
Group("change_logs.table_name").
Find(&result)
if query.Error != nil {
log.Error("ChangeLogTables: %s", query.Error)
}
sort.Strings(result)
return result
}
// LogEvent puts in a generic/miscellaneous change log event (e.g. certification photo updates).
func LogEvent(aboutUser, adminUser *User, event, tableName string, tableID uint64, message string) (*ChangeLog, error) {
cl := &ChangeLog{
TableName: tableName,
TableID: tableID,
Event: event,
Message: message,
}
if aboutUser != nil {
cl.AboutUserID = aboutUser.ID
}
if adminUser != nil && adminUser != aboutUser {
cl.AdminUserID = adminUser.ID
}
result := DB.Create(cl)
return cl, result.Error
}
// LogCreated puts in a ChangeLog "created" event.
func LogCreated(aboutUser *User, tableName string, tableID uint64, message string) (*ChangeLog, error) {
cl := &ChangeLog{
TableName: tableName,
TableID: tableID,
Event: ChangeLogCreated,
Message: message,
}
if aboutUser != nil {
cl.AboutUserID = aboutUser.ID
}
result := DB.Create(cl)
return cl, result.Error
}
// LogDeleted puts in a ChangeLog "deleted" event.
func LogDeleted(aboutUser, adminUser *User, tableName string, tableID uint64, message string, original interface{}) (*ChangeLog, error) {
// If the original model is given, JSON serialize it nicely.
if original != nil {
w := bytes.NewBuffer([]byte{})
enc := json.NewEncoder(w)
enc.SetIndent("\n", "* ")
if err := enc.Encode(original); err != nil {
log.Error("LogDeleted(%s %d): couldn't encode original model to JSON: %s", tableName, tableID, err)
} else {
message += "\n\n" + w.String()
}
}
cl := &ChangeLog{
TableName: tableName,
TableID: tableID,
Event: ChangeLogDeleted,
Message: message,
}
if aboutUser != nil {
cl.AboutUserID = aboutUser.ID
}
if adminUser != nil && adminUser != aboutUser {
cl.AdminUserID = adminUser.ID
}
result := DB.Create(cl)
return cl, result.Error
}
type FieldDiff struct {
Key string
Before interface{}
After interface{}
}
func NewFieldDiff(key string, before, after interface{}) FieldDiff {
return FieldDiff{
Key: key,
Before: before,
After: after,
}
}
// LogUpdated puts in a ChangeLog "updated" event.
func LogUpdated(aboutUser, adminUser *User, tableName string, tableID uint64, message string, diffs []FieldDiff) (*ChangeLog, error) {
// Append field diffs to the message?
lines := []string{message}
if len(diffs) > 0 {
lines = append(lines, "")
for _, row := range diffs {
var (
before = fmt.Sprintf("%v", row.Before)
after = fmt.Sprintf("%v", row.After)
)
if before != after {
lines = append(lines,
fmt.Sprintf("* **%s** changed to <code>%s</code> from <code>%s</code>",
row.Key,
strings.ReplaceAll(after, "`", "'"),
strings.ReplaceAll(before, "`", "'"),
),
)
}
}
}
cl := &ChangeLog{
TableName: tableName,
TableID: tableID,
Event: ChangeLogUpdated,
Message: strings.Join(lines, "\n"),
}
if aboutUser != nil {
cl.AboutUserID = aboutUser.ID
}
if adminUser != nil && adminUser != aboutUser {
cl.AdminUserID = adminUser.ID
}
result := DB.Create(cl)
return cl, result.Error
}

View File

@ -13,12 +13,12 @@ import (
// Comment table - in forum threads, on profiles or photos, etc. // Comment table - in forum threads, on profiles or photos, etc.
type Comment struct { type Comment struct {
ID uint64 `gorm:"primaryKey"` ID uint64 `gorm:"primaryKey"`
TableName string `gorm:"index:idx_comment_composite"` TableName string `gorm:"index"`
TableID uint64 `gorm:"index:idx_comment_composite"` TableID uint64 `gorm:"index"`
UserID uint64 `gorm:"index"` UserID uint64 `gorm:"index"`
User User `json:"-"` User User
Message string Message string
CreatedAt time.Time `gorm:"index"` CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
@ -29,16 +29,6 @@ var CommentableTables = map[string]interface{}{
"threads": nil, "threads": nil,
} }
// SubscribableTables are the set of table names that allow notification subscriptions.
var SubscribableTables = map[string]interface{}{
"photos": nil,
"threads": nil,
// Special case: new photo uploads from your friends. You can't comment on this,
// but you can (un)subscribe from it all the same.
"friend.photos": nil,
}
// Preload related tables for the forum (classmethod). // Preload related tables for the forum (classmethod).
func (c *Comment) Preload() *gorm.DB { func (c *Comment) Preload() *gorm.DB {
return DB.Preload("User.ProfilePhoto") return DB.Preload("User.ProfilePhoto")
@ -104,27 +94,21 @@ func CountCommentsReceived(user *User) int64 {
} }
// PaginateComments provides a page of comments on something. // PaginateComments provides a page of comments on something.
// func PaginateComments(user *User, tableName string, tableID uint64, pager *Pagination) ([]*Comment, error) {
// Note: noBlockLists is to facilitate user-owned forums, where forum owners/moderators should override the block lists
// and retain full visibility into all user comments on their forum. Default/recommended is to leave it false, where
// the user's block list filters the view.
func PaginateComments(user *User, tableName string, tableID uint64, noBlockLists bool, pager *Pagination) ([]*Comment, error) {
var ( var (
cs = []*Comment{} cs = []*Comment{}
query = (&Comment{}).Preload() query = (&Comment{}).Preload()
wheres = []string{} blockedUserIDs = BlockedUserIDs(user)
placeholders = []interface{}{} wheres = []string{}
placeholders = []interface{}{}
) )
wheres = append(wheres, "table_name = ? AND table_id = ?") wheres = append(wheres, "table_name = ? AND table_id = ?")
placeholders = append(placeholders, tableName, tableID) placeholders = append(placeholders, tableName, tableID)
if !noBlockLists { if len(blockedUserIDs) > 0 {
blockedUserIDs := BlockedUserIDs(user) wheres = append(wheres, "user_id NOT IN ?")
if len(blockedUserIDs) > 0 { placeholders = append(placeholders, blockedUserIDs)
wheres = append(wheres, "user_id NOT IN ?")
placeholders = append(placeholders, blockedUserIDs)
}
} }
// Don't show comments from banned or disabled accounts. // Don't show comments from banned or disabled accounts.
@ -188,14 +172,6 @@ func FindPageByComment(user *User, comment *Comment, pageSize int) (int, error)
for i, cid := range allCommentIDs { for i, cid := range allCommentIDs {
if cid == comment.ID { if cid == comment.ID {
var page = int(math.Ceil(float64(i) / float64(pageSize))) var page = int(math.Ceil(float64(i) / float64(pageSize)))
// If the comment index is an equal multiple of the page size
// (e.g. comment #20 is the 1st comment on page 2, since 0-19 is page 1),
// account for an off-by-one error.
if i%pageSize == 0 {
page++
}
if page == 0 { if page == 0 {
page = 1 page = 1
} }

View File

@ -89,16 +89,9 @@ func MapCommentPhotos(comments []*Comment) (CommentPhotoMap, error) {
) )
for _, c := range comments { for _, c := range comments {
if c == nil {
continue
}
IDs = append(IDs, c.ID) IDs = append(IDs, c.ID)
} }
if len(IDs) == 0 {
return result, nil
}
res := DB.Model(&CommentPhoto{}).Where("comment_id IN ?", IDs).Find(&ps) res := DB.Model(&CommentPhoto{}).Where("comment_id IN ?", IDs).Find(&ps)
if res.Error != nil { if res.Error != nil {
return nil, res.Error return nil, res.Error
@ -134,15 +127,7 @@ func GetOrphanedCommentPhotos() ([]*CommentPhoto, int64, error) {
ps = []*CommentPhoto{} ps = []*CommentPhoto{}
) )
query := DB.Model(&CommentPhoto{}).Where(` query := DB.Model(&CommentPhoto{}).Where("comment_id = 0 AND created_at < ?", cutoff)
(comment_id <> 0 AND NOT EXISTS (
SELECT 1 FROM comments
WHERE comments.id = comment_photos.comment_id
))
OR
(comment_id = 0 AND created_at < ?)`,
cutoff,
)
query.Count(&count) query.Count(&count)
res := query.Limit(500).Find(&ps) res := query.Limit(500).Find(&ps)
if res.Error != nil { if res.Error != nil {

View File

@ -3,7 +3,6 @@ package deletion
import ( import (
"fmt" "fmt"
"code.nonshy.com/nonshy/website/pkg/chat"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/photo" "code.nonshy.com/nonshy/website/pkg/photo"
@ -13,17 +12,6 @@ import (
func DeleteUser(user *models.User) error { func DeleteUser(user *models.User) error {
log.Error("BEGIN DeleteUser(%d, %s)", user.ID, user.Username) log.Error("BEGIN DeleteUser(%d, %s)", user.ID, user.Username)
// Clear their history on the chat room.
go func() {
i, err := chat.EraseChatHistory(user.Username)
if err != nil {
log.Error("EraseChatHistory(%s): %s", user.Username, err)
return
}
log.Error("DeleteUser(%s): Cleared chat DMs history for user (%d messages erased)", user.Username, i)
}()
// Remove all linked tables and assets. // Remove all linked tables and assets.
type remover struct { type remover struct {
Step string Step string
@ -36,8 +24,6 @@ func DeleteUser(user *models.User) error {
// Tables to remove. In case of any unexpected DB errors, these tables are ordered // Tables to remove. In case of any unexpected DB errors, these tables are ordered
// to remove the "safest" fields first. // to remove the "safest" fields first.
var todo = []remover{ var todo = []remover{
{"Admin group memberships", DeleteAdminGroupUsers},
{"Disown User Forums", DisownForums},
{"Notifications", DeleteNotifications}, {"Notifications", DeleteNotifications},
{"Likes", DeleteLikes}, {"Likes", DeleteLikes},
{"Threads", DeleteForumThreads}, {"Threads", DeleteForumThreads},
@ -55,11 +41,6 @@ func DeleteUser(user *models.User) error {
{"Two Factor", DeleteTwoFactor}, {"Two Factor", DeleteTwoFactor},
{"Profile Fields", DeleteProfile}, {"Profile Fields", DeleteProfile},
{"User Notes", DeleteUserNotes}, {"User Notes", DeleteUserNotes},
{"Change Logs", DeleteChangeLogs},
{"IP Addresses", DeleteIPAddresses},
{"Push Notifications", DeletePushNotifications},
{"Forum Memberships", DeleteForumMemberships},
{"Usage Statistics", DeleteUsageStatistics},
} }
for _, item := range todo { for _, item := range todo {
if err := item.Fn(user.ID); err != nil { if err := item.Fn(user.ID); err != nil {
@ -71,16 +52,6 @@ func DeleteUser(user *models.User) error {
return user.Delete() return user.Delete()
} }
// DeleteAdminGroupUsers scrubs data for deleting a user.
func DeleteAdminGroupUsers(userID uint64) error {
log.Error("DeleteUser: DeleteAdminGroupUsers(%d)", userID)
result := models.DB.Exec(
"DELETE FROM admin_group_users WHERE user_id = ?",
userID,
)
return result.Error
}
// DeleteUserPhotos scrubs data for deleting a user. // DeleteUserPhotos scrubs data for deleting a user.
func DeleteUserPhotos(userID uint64) error { func DeleteUserPhotos(userID uint64) error {
log.Error("DeleteUser: BEGIN DeleteUserPhotos(%d)", userID) log.Error("DeleteUser: BEGIN DeleteUserPhotos(%d)", userID)
@ -356,64 +327,3 @@ func DeleteUserNotes(userID uint64) error {
).Delete(&models.UserNote{}) ).Delete(&models.UserNote{})
return result.Error return result.Error
} }
// DeleteChangeLogs scrubs data for deleting a user.
func DeleteChangeLogs(userID uint64) error {
log.Error("DeleteUser: DeleteChangeLogs(%d)", userID)
result := models.DB.Where(
"about_user_id = ?",
userID,
).Delete(&models.ChangeLog{})
return result.Error
}
// DeleteIPAddresses scrubs data for deleting a user.
func DeleteIPAddresses(userID uint64) error {
log.Error("DeleteUser: DeleteIPAddresses(%d)", userID)
result := models.DB.Where(
"user_id = ?",
userID,
).Delete(&models.IPAddress{})
return result.Error
}
// DeletePushNotifications scrubs data for deleting a user.
func DeletePushNotifications(userID uint64) error {
log.Error("DeleteUser: DeletePushNotifications(%d)", userID)
result := models.DB.Where(
"user_id = ?",
userID,
).Delete(&models.PushNotification{})
return result.Error
}
// DisownForums unlinks the user from their owned forums.
func DisownForums(userID uint64) error {
log.Error("DeleteUser: DisownForums(%d)", userID)
result := models.DB.Exec(`
UPDATE forums
SET owner_id = NULL
WHERE owner_id = ?
`, userID)
return result.Error
}
// DeleteForumMemberships scrubs data for deleting a user.
func DeleteForumMemberships(userID uint64) error {
log.Error("DeleteUser: DeleteForumMemberships(%d)", userID)
result := models.DB.Where(
"user_id = ?",
userID,
).Delete(&models.ForumMembership{})
return result.Error
}
// DeleteUsageStatistics scrubs data for deleting a user.
func DeleteUsageStatistics(userID uint64) error {
log.Error("DeleteUser: DeleteUsageStatistics(%d)", userID)
result := models.DB.Where(
"user_id = ?",
userID,
).Delete(&models.UsageStatistic{})
return result.Error
}

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,6 @@ import (
"strings" "strings"
"time" "time"
"code.nonshy.com/nonshy/website/pkg/config"
"gorm.io/gorm" "gorm.io/gorm"
) )
@ -21,14 +20,14 @@ type Forum struct {
Explicit bool `gorm:"index"` Explicit bool `gorm:"index"`
Privileged bool Privileged bool
PermitPhotos bool PermitPhotos bool
Private bool `gorm:"index"` InnerCircle bool
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
// Preload related tables for the forum (classmethod). // Preload related tables for the forum (classmethod).
func (f *Forum) Preload() *gorm.DB { func (f *Forum) Preload() *gorm.DB {
return DB.Preload("Owner").Preload("Owner.ProfilePhoto") return DB.Preload("Owner")
} }
// GetForum by ID. // GetForum by ID.
@ -70,13 +69,6 @@ func ForumByFragment(fragment string) (*Forum, error) {
return f, result.Error return f, result.Error
} }
// CanEdit checks if the user has edit rights over this forum.
//
// That is, they are its Owner or they are an admin with Manage Forums permission.
func (f *Forum) CanEdit(user *User) bool {
return user.HasAdminScope(config.ScopeForumAdmin) || f.OwnerID == user.ID
}
/* /*
PaginateForums scans over the available forums for a user. PaginateForums scans over the available forums for a user.
@ -85,15 +77,8 @@ Parameters:
- userID: of who is looking - userID: of who is looking
- categories: optional, filter within categories - categories: optional, filter within categories
- pager - pager
The pager Sort accepts a couple of custom values for more advanced sorting:
- by_latest: recently updated posts
- by_threads: thread count
- by_posts: post count
- by_users: user count
*/ */
func PaginateForums(user *User, categories []string, search *Search, subscribed bool, pager *Pagination) ([]*Forum, error) { func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Forum, error) {
var ( var (
fs = []*Forum{} fs = []*Forum{}
query = (&Forum{}).Preload() query = (&Forum{}).Preload()
@ -111,55 +96,9 @@ func PaginateForums(user *User, categories []string, search *Search, subscribed
wheres = append(wheres, "explicit = false") wheres = append(wheres, "explicit = false")
} }
// Hide private forums except for admins and approved users. // Hide circle forums if the user isn't in the circle.
if !user.IsAdmin { if !user.IsInnerCircle() {
wheres = append(wheres, ` wheres = append(wheres, "inner_circle is not true")
(
private IS NOT TRUE
OR EXISTS (
SELECT 1
FROM forum_memberships
WHERE forum_id = forums.id
AND user_id = ?
AND (
is_moderator IS TRUE
OR approved IS TRUE
)
)
)`,
)
placeholders = append(placeholders, user.ID)
}
// Followed forums only? (for the My List category on home page)
if subscribed {
wheres = append(wheres, `
EXISTS (
SELECT 1
FROM forum_memberships
WHERE user_id = ?
AND forum_id = forums.id
)
OR (
forums.owner_id = ?
AND (forums.category = '' OR forums.category IS NULL)
)
`)
placeholders = append(placeholders, user.ID, user.ID)
}
// Apply their search terms.
if search != nil {
for _, term := range search.Includes {
var ilike = "%" + strings.ToLower(term) + "%"
wheres = append(wheres, "(fragment ILIKE ? OR title ILIKE ? OR description ILIKE ?)")
placeholders = append(placeholders, ilike, ilike, ilike)
}
for _, term := range search.Excludes {
var ilike = "%" + strings.ToLower(term) + "%"
wheres = append(wheres, "(fragment NOT ILIKE ? AND title NOT ILIKE ? AND description NOT ILIKE ?)")
placeholders = append(placeholders, ilike, ilike, ilike)
}
} }
// Filters? // Filters?
@ -170,43 +109,6 @@ func PaginateForums(user *User, categories []string, search *Search, subscribed
) )
} }
// Custom SORT parameters.
switch pager.Sort {
case "by_followers":
pager.Sort = `(
SELECT count(forum_memberships.id)
FROM forum_memberships
WHERE forum_memberships.forum_id = forums.id
) DESC`
case "by_latest":
pager.Sort = `(
SELECT MAX(threads.updated_at)
FROM threads
WHERE threads.forum_id = forums.id
) DESC NULLS LAST`
case "by_threads":
pager.Sort = `(
SELECT count(threads.id)
FROM threads
WHERE threads.forum_id = forums.id
) DESC`
case "by_posts":
pager.Sort = `(
SELECT count(comments.id)
FROM threads
JOIN comments ON comments.table_name='threads' AND comments.table_id=threads.id
WHERE threads.forum_id = forums.id
) DESC`
case "by_users":
pager.Sort = `(
SELECT count(distinct(users.id))
FROM threads
JOIN comments ON comments.table_name='threads' AND comments.table_id=threads.id
JOIN users ON comments.user_id=users.id
WHERE threads.forum_id = forums.id
) DESC`
}
query = query.Order(pager.Sort) query = query.Order(pager.Sort)
query.Model(&Forum{}).Count(&pager.Total) query.Model(&Forum{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs) result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
@ -214,44 +116,20 @@ func PaginateForums(user *User, categories []string, search *Search, subscribed
} }
// PaginateOwnedForums returns forums the user owns (or all forums to admins). // PaginateOwnedForums returns forums the user owns (or all forums to admins).
func PaginateOwnedForums(userID uint64, isAdmin bool, categories []string, search *Search, pager *Pagination) ([]*Forum, error) { func PaginateOwnedForums(userID uint64, isAdmin bool, pager *Pagination) ([]*Forum, error) {
var ( var (
fs = []*Forum{} fs = []*Forum{}
query = (&Forum{}).Preload() query = (&Forum{}).Preload()
wheres = []string{}
placeholders = []interface{}{}
) )
// Users see only their owned forums.
if !isAdmin { if !isAdmin {
wheres = append(wheres, "owner_id = ?") query = query.Where(
placeholders = append(placeholders, userID) "owner_id = ?",
userID,
)
} }
if len(categories) > 0 { query = query.Order(pager.Sort)
wheres = append(wheres, "category IN ?")
placeholders = append(placeholders, categories)
}
// Apply their search terms.
if search != nil {
for _, term := range search.Includes {
var ilike = "%" + strings.ToLower(term) + "%"
wheres = append(wheres, "(fragment ILIKE ? OR title ILIKE ? OR description ILIKE ?)")
placeholders = append(placeholders, ilike, ilike, ilike)
}
for _, term := range search.Excludes {
var ilike = "%" + strings.ToLower(term) + "%"
wheres = append(wheres, "(fragment NOT ILIKE ? AND title NOT ILIKE ? AND description NOT ILIKE ?)")
placeholders = append(placeholders, ilike, ilike, ilike)
}
}
query = query.Where(
strings.Join(wheres, " AND "),
placeholders...,
).Order(pager.Sort)
query.Model(&Forum{}).Count(&pager.Total) query.Model(&Forum{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs) result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
return fs, result.Error return fs, result.Error
@ -281,15 +159,6 @@ func CategorizeForums(fs []*Forum, categories []string) []*CategorizedForum {
idxMap = map[string]int{} idxMap = map[string]int{}
) )
// Forum Browse page: we are not grouping by categories but still need at least one.
if len(categories) == 0 {
return []*CategorizedForum{
{
Forums: fs,
},
}
}
// Initialize the result set. // Initialize the result set.
for i, category := range categories { for i, category := range categories {
result = append(result, &CategorizedForum{ result = append(result, &CategorizedForum{

View File

@ -1,291 +0,0 @@
package models
import (
"errors"
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"gorm.io/gorm"
)
// ForumMembership table.
//
// Unique key constraint pairs user_id and forum_id.
type ForumMembership struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"uniqueIndex:idx_forum_membership"`
User User `gorm:"foreignKey:user_id"`
ForumID uint64 `gorm:"uniqueIndex:idx_forum_membership"`
Forum Forum `gorm:"foreignKey:forum_id"`
Approved bool `gorm:"index"`
IsModerator bool `gorm:"index"`
CreatedAt time.Time
UpdatedAt time.Time
}
// Preload related tables for the forum (classmethod).
func (f *ForumMembership) Preload() *gorm.DB {
return DB.Preload("User").Preload("Forum")
}
// CreateForumMembership subscribes the user to a forum.
func CreateForumMembership(user *User, forum *Forum) (*ForumMembership, error) {
var (
f = &ForumMembership{
User: *user,
Forum: *forum,
Approved: true,
}
result = DB.Create(f)
)
return f, result.Error
}
// GetForumMembership looks up a forum membership, returning an error if one is not found.
func GetForumMembership(user *User, forum *Forum) (*ForumMembership, error) {
var (
f = &ForumMembership{}
result = f.Preload().Where(
"user_id = ? AND forum_id = ?",
user.ID, forum.ID,
).First(&f)
)
return f, result.Error
}
// AddModerator appoints a moderator to the forum, returning that user's ForumMembership.
//
// If the target is not following the forum, a ForumMembership is created, marked as a moderator and returned.
func (f *Forum) AddModerator(user *User) (*ForumMembership, error) {
var fm *ForumMembership
if found, err := GetForumMembership(user, f); err != nil {
fm = &ForumMembership{
User: *user,
Forum: *f,
Approved: true,
}
} else {
fm = found
}
// They are already a moderator?
if fm.IsModerator {
return fm, errors.New("they are already a moderator of this forum")
}
fm.IsModerator = true
err := fm.Save()
return fm, err
}
// CanBeSeenBy checks whether the user can see a private forum.
//
// Admins, owners, moderators and approved followers can see it.
//
// Note: this may invoke a DB query to check for moderator.
func (f *Forum) CanBeSeenBy(user *User) bool {
if !f.Private || user.IsAdmin || user.ID == f.OwnerID {
return true
}
if fm, err := GetForumMembership(user, f); err == nil {
return fm.Approved || fm.IsModerator
}
return false
}
// CanBeModeratedBy checks whether the user can moderate this forum.
//
// Admins, owners and moderators can do so.
//
// Note: this may invoke a DB query to check for moderator.
func (f *Forum) CanBeModeratedBy(user *User) bool {
if user.HasAdminScope(config.ScopeForumModerator) || f.OwnerID == user.ID {
return true
}
if fm, err := GetForumMembership(user, f); err == nil {
return fm.IsModerator
}
return false
}
// RemoveModerator will unset a user's moderator flag on this forum.
func (f *Forum) RemoveModerator(user *User) (*ForumMembership, error) {
fm, err := GetForumMembership(user, f)
if err != nil {
return nil, err
}
fm.IsModerator = false
err = fm.Save()
return fm, err
}
// GetModerators loads all of the moderators of a forum, ordered alphabetically by username.
func (f *Forum) GetModerators() ([]*User, error) {
// Find all forum memberships that moderate us.
var (
fm = []*ForumMembership{}
result = (&ForumMembership{}).Preload().Where(
"forum_id = ? AND is_moderator IS TRUE",
f.ID,
).Find(&fm)
)
if result.Error != nil {
log.Error("Forum(%d).GetModerators(): %s", f.ID, result.Error)
return nil, nil
}
// Load these users.
var userIDs = []uint64{}
for _, row := range fm {
userIDs = append(userIDs, row.UserID)
}
return GetUsersAlphabetically(userIDs)
}
// IsForumSubscribed checks if the current user subscribes to this forum.
func IsForumSubscribed(user *User, forum *Forum) bool {
f, _ := GetForumMembership(user, forum)
return f.UserID == user.ID
}
// HasForumSubscriptions returns if the current user has at least one forum membership.
func (u *User) HasForumSubscriptions() bool {
var count int64
DB.Model(&ForumMembership{}).Where(
"user_id = ?",
u.ID,
).Count(&count)
return count > 0
}
// CountForumMemberships counts how many subscribers a forum has.
func CountForumMemberships(forum *Forum) int64 {
var count int64
DB.Model(&ForumMembership{}).Where(
"forum_id = ?",
forum.ID,
).Count(&count)
return count
}
// Save a forum membership.
func (f *ForumMembership) Save() error {
return DB.Save(f).Error
}
// Delete a forum membership.
func (f *ForumMembership) Delete() error {
return DB.Delete(f).Error
}
// PaginateForumMemberships paginates over a user's ForumMemberships.
func PaginateForumMemberships(user *User, pager *Pagination) ([]*ForumMembership, error) {
var (
fs = []*ForumMembership{}
query = (&ForumMembership{}).Preload()
wheres = []string{}
placeholders = []interface{}{}
)
query = query.Where(
strings.Join(wheres, " AND "),
placeholders...,
).Order(pager.Sort)
query.Model(&ForumMembership{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
return fs, result.Error
}
// ForumMembershipMap maps table IDs to Likes metadata.
type ForumMembershipMap map[uint64]bool
// Get like stats from the map.
func (fm ForumMembershipMap) Get(id uint64) bool {
return fm[id]
}
// MapForumMemberships looks up a user's memberships in bulk.
func MapForumMemberships(user *User, forums []*Forum) ForumMembershipMap {
var (
result = ForumMembershipMap{}
forumIDs = []uint64{}
)
// Initialize the result set.
for _, forum := range forums {
result[forum.ID] = false
forumIDs = append(forumIDs, forum.ID)
}
// Map the forum IDs the user subscribes to.
var followIDs = []uint64{}
if res := DB.Model(&ForumMembership{}).Select(
"forum_id",
).Where(
"user_id = ? AND forum_id IN ?",
user.ID, forumIDs,
).Scan(&followIDs); res.Error != nil {
log.Error("MapForumMemberships: %s", res.Error)
}
for _, forumID := range followIDs {
result[forumID] = true
}
return result
}
// ForumFollowerMap maps table IDs to counts of memberships.
type ForumFollowerMap map[uint64]int64
// Get like stats from the map.
func (fm ForumFollowerMap) Get(id uint64) int64 {
return fm[id]
}
// MapForumFollowers maps out the count of followers for a set of forums.
func MapForumFollowers(forums []*Forum) ForumFollowerMap {
var (
result = ForumFollowerMap{}
forumIDs = []uint64{}
)
// Initialize the result set.
for _, forum := range forums {
forumIDs = append(forumIDs, forum.ID)
}
// Hold the result of the grouped count query.
type group struct {
ID uint64
Followers int64
}
var groups = []group{}
// Map the counts of likes to each of these IDs.
if res := DB.Model(
&ForumMembership{},
).Select(
"forum_id AS id, count(id) AS followers",
).Where(
"forum_id IN ?",
forumIDs,
).Group("forum_id").Scan(&groups); res.Error != nil {
log.Error("MapLikes: count query: %s", res.Error)
}
for _, row := range groups {
result[row.ID] = row.Followers
}
return result
}

View File

@ -1,67 +0,0 @@
package models
import (
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
)
// Functions dealing with quota allowances for user-created forums.
// ComputeForumQuota returns a count of how many user-created forums this user is allowed to own.
//
// The forum quota slowly increases over time and quantity of forum posts written by this user.
func ComputeForumQuota(user *User) int64 {
var (
credits int64
numPosts = CountCommentsByUser(user, "threads")
)
// Get the user's certification date. They get one credit for having a long standing
// certified status on their account.
if certSince, err := user.CertifiedSince(); err != nil {
return 0
} else if time.Since(certSince) > config.UserForumQuotaCertLifetimeDays {
credits++
}
// Take their number of posts and compute their quota.
var schedule int64
for _, schedule = range config.UserForumQuotaCommentCountSchedule {
if numPosts > schedule {
credits++
numPosts -= schedule
}
}
// If they still have posts, repeat the final schedule per credit.
for numPosts > schedule {
credits++
numPosts -= schedule
}
return credits
}
// ForumQuotaRemaining computes the amount of additional forums the current user can create.
func (u *User) ForumQuotaRemaining() int64 {
var (
quota = ComputeForumQuota(u)
owned = CountOwnedUserForums(u)
)
return quota - owned
}
// CountOwnedUserForums returns the total number of user forums owned by the given user.
func CountOwnedUserForums(user *User) int64 {
var count int64
result := DB.Model(&Forum{}).Where(
"owner_id = ? AND (category='' OR category IS NULL)",
user.ID,
).Count(&count)
if result.Error != nil {
log.Error("CountOwnedUserForums(%d): %s", user.ID, result.Error)
}
return count
}

View File

@ -1,11 +1,9 @@
package models package models
import ( import (
"sort"
"strings" "strings"
"time" "time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
) )
@ -22,19 +20,13 @@ type RecentPost struct {
} }
// PaginateRecentPosts returns all of the comments on a forum paginated. // PaginateRecentPosts returns all of the comments on a forum paginated.
func PaginateRecentPosts(user *User, categories []string, subscribed, allComments bool, pager *Pagination) ([]*RecentPost, error) { func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]*RecentPost, error) {
var ( var (
result = []*RecentPost{} result = []*RecentPost{}
query = (&Comment{}).Preload()
blockedUserIDs = BlockedUserIDs(user) blockedUserIDs = BlockedUserIDs(user)
wheres = []string{"table_name = 'threads'"}
// Separate the WHERE clauses that involve forums/threads from the ones
// that involve comments. Rationale: if the user is getting a de-duplicated
// thread view, we'll end up running two queries - one to get all threads and
// another to get the latest comments, and the WHERE clauses need to be separate.
wheres = []string{}
placeholders = []interface{}{} placeholders = []interface{}{}
comment_wheres = []string{"table_name = 'threads'"}
comment_ph = []interface{}{}
) )
if len(categories) > 0 { if len(categories) > 0 {
@ -47,47 +39,19 @@ func PaginateRecentPosts(user *User, categories []string, subscribed, allComment
wheres = append(wheres, "forums.explicit = false") wheres = append(wheres, "forums.explicit = false")
} }
// Private forums. // Circle membership.
if !user.IsAdmin { if !user.IsInnerCircle() {
wheres = append(wheres, ` wheres = append(wheres, "forums.inner_circle is not true")
(
forums.private IS NOT TRUE
OR EXISTS (
SELECT 1
FROM forum_memberships
WHERE forum_id = forums.id
AND user_id = ?
AND (
is_moderator IS TRUE
OR approved IS TRUE
)
)
)`,
)
placeholders = append(placeholders, user.ID)
}
// Forums I follow?
if subscribed {
wheres = append(wheres, `
EXISTS (
SELECT 1
FROM forum_memberships
WHERE user_id = ?
AND forum_id = forums.id
)
`)
placeholders = append(placeholders, user.ID)
} }
// Blocked users? // Blocked users?
if len(blockedUserIDs) > 0 { if len(blockedUserIDs) > 0 {
comment_wheres = append(comment_wheres, "comments.user_id NOT IN ?") wheres = append(wheres, "comments.user_id NOT IN ?")
comment_ph = append(comment_ph, blockedUserIDs) placeholders = append(placeholders, blockedUserIDs)
} }
// Don't show comments from banned or disabled accounts. // Don't show comments from banned or disabled accounts.
comment_wheres = append(comment_wheres, ` wheres = append(wheres, `
EXISTS ( EXISTS (
SELECT 1 SELECT 1
FROM users FROM users
@ -97,25 +61,30 @@ func PaginateRecentPosts(user *User, categories []string, subscribed, allComment
`) `)
// Get the page of recent forum comment IDs of all time. // Get the page of recent forum comment IDs of all time.
var scan NewestForumPostsScanner type scanner struct {
CommentID uint64
ThreadID *uint64
ForumID *uint64
}
var scan []scanner
query = DB.Table("comments").Select(
`comments.id AS comment_id,
threads.id AS thread_id,
forums.id AS forum_id`,
).Joins(
"LEFT OUTER JOIN threads ON (table_name = 'threads' AND table_id = threads.id)",
).Joins(
"LEFT OUTER JOIN forums ON (threads.forum_id = forums.id)",
).Where(
strings.Join(wheres, " AND "),
placeholders...,
).Order("comments.updated_at desc")
// Deduplicate forum threads: if one thread is BLOWING UP with replies, we should only // Get the total for the pager and scan the page of ID sets.
// mention the thread once and show the newest comment so it doesn't spam the whole page. query.Model(&Comment{}).Count(&pager.Total)
if config.Current.Database.IsPostgres && !allComments { query = query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&scan)
// Note: only Postgres supports this function (SELECT DISTINCT ON). if query.Error != nil {
if res, err := ScanLatestForumCommentsPerThread(wheres, comment_wheres, placeholders, comment_ph, pager); err != nil { return nil, query.Error
return nil, err
} else {
scan = res
}
} else {
// SQLite/non-Postgres doesn't support DISTINCT ON, this is the old query which
// shows objectively all comments and a popular thread may dominate the page.
if res, err := ScanLatestForumCommentsAll(wheres, comment_wheres, placeholders, comment_ph, pager); err != nil {
return nil, err
} else {
scan = res
}
} }
// Ingest the results. // Ingest the results.
@ -212,13 +181,6 @@ func PaginateRecentPosts(user *User, categories []string, subscribed, allComment
} }
} }
// Is the new comment unavailable? (e.g. blocked, banned, disabled)
if rc.Comment == nil {
rc.Comment = &Comment{
Message: "[unavailable]",
}
}
if f, ok := forums[rc.ForumID]; ok { if f, ok := forums[rc.ForumID]; ok {
rc.Forum = f rc.Forum = f
} }
@ -230,140 +192,3 @@ func PaginateRecentPosts(user *User, categories []string, subscribed, allComment
return result, nil return result, nil
} }
// NewestForumPosts collects the IDs of the latest forum posts.
type NewestForumPosts struct {
CommentID uint64
ThreadID *uint64
ForumID *uint64
UpdatedAt time.Time
}
type NewestForumPostsScanner []NewestForumPosts
// ScanLatestForumCommentsAll returns a scan of Newest forum posts containing ALL comments, which may
// include runs of 'duplicate' forum threads if a given thread was commented on rapidly. This is the classic
// 'Newest' tab behavior, showing just ALL forum comments by newest.
func ScanLatestForumCommentsAll(wheres, comment_wheres []string, placeholders, comment_ph []interface{}, pager *Pagination) (NewestForumPostsScanner, error) {
var scan NewestForumPostsScanner
// This one is all one joined query so join the wheres/placeholders.
wheres = append(wheres, comment_wheres...)
placeholders = append(placeholders, comment_ph...)
// SQLite/non-Postgres doesn't support DISTINCT ON, this is the old query which
// shows objectively all comments and a popular thread may dominate the page.
query := DB.Table("comments").Select(
`comments.id AS comment_id,
threads.id AS thread_id,
forums.id AS forum_id,
comments.updated_at AS updated_at`,
).Joins(
"LEFT OUTER JOIN threads ON (table_name = 'threads' AND table_id = threads.id)",
).Joins(
"LEFT OUTER JOIN forums ON (threads.forum_id = forums.id)",
).Where(
strings.Join(wheres, " AND "),
placeholders...,
).Order("comments.updated_at desc")
query.Model(&Comment{}).Count(&pager.Total)
// Execute the query.
query = query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&scan)
return scan, query.Error
}
// ScanLatestForumCommentsPerThread returns a scan of Newest forum posts, deduplicated by thread.
// Each thread ID will only appear once in the result, paired with the newest comment in that
// thread.
func ScanLatestForumCommentsPerThread(wheres, comment_wheres []string, placeholders, comment_ph []interface{}, pager *Pagination) (NewestForumPostsScanner, error) {
var (
result NewestForumPostsScanner
threadIDs = []uint64{}
// Query for ALL thread IDs (in forums the user can see).
query = DB.Table(
"threads",
).Select(`
DISTINCT ON (threads.id)
threads.forum_id,
threads.id AS thread_id,
threads.updated_at AS updated_at
`).Joins(
"JOIN forums ON (threads.forum_id = forums.id)",
).Where(
strings.Join(wheres, " AND "),
placeholders...,
).Order(
"threads.id",
)
)
query = query.Find(&result)
if query.Error != nil {
return result, query.Error
}
pager.Total = int64(len(result))
// Reorder the result by timestamp.
sort.Slice(result, func(i, j int) bool {
return result[i].UpdatedAt.After(result[j].UpdatedAt)
})
// Subslice the result per the user's pagination setting.
var (
start = pager.GetOffset()
stop = start + pager.PerPage
)
if start > len(result) {
return NewestForumPostsScanner{}, nil
} else if stop > len(result) {
stop = len(result)
}
result = result[start:stop]
// Map the thread IDs to their result row.
var threadMap = map[uint64]int{}
for i, row := range result {
threadIDs = append(threadIDs, *row.ThreadID)
threadMap[*row.ThreadID] = i
}
// With these thread IDs, select the newest comments.
type scanner struct {
ThreadID uint64
CommentID uint64
}
var scan []scanner
err := DB.Table(
"comments",
).Select(
"table_id AS thread_id, id AS comment_id",
).Where(
`table_name='threads' AND table_id IN ?
AND updated_at = (SELECT MAX(updated_at)
FROM comments c2
WHERE c2.table_name=comments.table_name
AND c2.table_id=comments.table_id
)`,
threadIDs,
).Where(
strings.Join(comment_wheres, " AND "),
comment_ph...,
).Order(
"updated_at desc",
).Scan(&scan)
if err.Error != nil {
log.Error("Getting most recent post IDs: %s", err.Error)
return result, err.Error
}
// Populate the comment IDs back in.
for _, row := range scan {
if idx, ok := threadMap[row.ThreadID]; ok {
result[idx].CommentID = row.CommentID
}
}
return result, query.Error
}

View File

@ -81,7 +81,7 @@ type ForumSearchFilters struct {
} }
// SearchForum searches the forum. // SearchForum searches the forum.
func SearchForum(user *User, categories []string, search *Search, filters ForumSearchFilters, pager *Pagination) ([]*Comment, error) { func SearchForum(user *User, search *Search, filters ForumSearchFilters, pager *Pagination) ([]*Comment, error) {
var ( var (
coms = []*Comment{} coms = []*Comment{}
query = (&Comment{}).Preload() query = (&Comment{}).Preload()
@ -90,19 +90,14 @@ func SearchForum(user *User, categories []string, search *Search, filters ForumS
placeholders = []interface{}{} placeholders = []interface{}{}
) )
if len(categories) > 0 {
wheres = append(wheres, "category IN ?")
placeholders = append(placeholders, categories)
}
// Hide explicit forum if user hasn't opted into it. // Hide explicit forum if user hasn't opted into it.
if !user.Explicit && !user.IsAdmin { if !user.Explicit && !user.IsAdmin {
wheres = append(wheres, "forums.explicit = false") wheres = append(wheres, "forums.explicit = false")
} }
// Private forums. // Circle membership.
if !user.IsAdmin { if !user.IsInnerCircle() {
wheres = append(wheres, "forums.private is not true") wheres = append(wheres, "forums.inner_circle is not true")
} }
// Blocked users? // Blocked users?

View File

@ -230,6 +230,7 @@ func (ts ForumStatsMap) generateRecentPosts(IDs []uint64) {
"comments", "comments",
).Select( ).Select(
"table_id AS thread_id, id AS comment_id", "table_id AS thread_id, id AS comment_id",
// "forum_id, id AS thread_id, updated_at",
).Where( ).Where(
`table_name='threads' AND table_id IN ? `table_name='threads' AND table_id IN ?
AND updated_at = (SELECT MAX(updated_at) AND updated_at = (SELECT MAX(updated_at)

View File

@ -6,6 +6,7 @@ import (
"time" "time"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
"gorm.io/gorm"
) )
// Friend table. // Friend table.
@ -16,7 +17,7 @@ type Friend struct {
Approved bool `gorm:"index"` Approved bool `gorm:"index"`
Ignored bool Ignored bool
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time `gorm:"index"` UpdatedAt time.Time
} }
// AddFriend sends a friend request or accepts one if there was already a pending one. // AddFriend sends a friend request or accepts one if there was already a pending one.
@ -248,19 +249,11 @@ func FriendIDsInCircleAreExplicit(userId uint64) []uint64 {
// CountFriendRequests gets a count of pending requests for the user. // CountFriendRequests gets a count of pending requests for the user.
func CountFriendRequests(userID uint64) (int64, error) { func CountFriendRequests(userID uint64) (int64, error) {
var ( var count int64
count int64
wheres = []string{
"target_user_id = ? AND approved = ? AND ignored IS NOT true",
"EXISTS (SELECT 1 FROM users WHERE users.id = source_user_id AND users.status = 'active')",
}
placeholders = []interface{}{
userID, false,
}
)
result := DB.Where( result := DB.Where(
strings.Join(wheres, " AND "), "target_user_id = ? AND approved = ? AND ignored IS NOT true",
placeholders..., userID,
false,
).Model(&Friend{}).Count(&count) ).Model(&Friend{}).Count(&count)
return count, result.Error return count, result.Error
} }
@ -269,7 +262,7 @@ func CountFriendRequests(userID uint64) (int64, error) {
func CountIgnoredFriendRequests(userID uint64) (int64, error) { func CountIgnoredFriendRequests(userID uint64) (int64, error) {
var count int64 var count int64
result := DB.Where( result := DB.Where(
"target_user_id = ? AND approved = ? AND ignored = ? AND EXISTS (SELECT 1 FROM users WHERE users.id = friends.source_user_id AND users.status = 'active')", "target_user_id = ? AND approved = ? AND ignored = ?",
userID, userID,
false, false,
true, true,
@ -302,77 +295,38 @@ have sent and have not been answered.
func PaginateFriends(user *User, requests bool, sent bool, ignored bool, pager *Pagination) ([]*User, error) { func PaginateFriends(user *User, requests bool, sent bool, ignored bool, pager *Pagination) ([]*User, error) {
// We paginate over the Friend table. // We paginate over the Friend table.
var ( var (
fs = []*Friend{} fs = []*Friend{}
userIDs = []uint64{} userIDs = []uint64{}
blockedUserIDs = BlockedUserIDs(user) query *gorm.DB
wheres = []string{}
placeholders = []interface{}{}
query = DB.Model(&Friend{})
) )
if requests && sent && ignored { if requests && sent && ignored {
return nil, errors.New("requests and sent are mutually exclusive options, use one or neither") return nil, errors.New("requests and sent are mutually exclusive options, use one or neither")
} }
// Don't show our blocked users in the result.
if len(blockedUserIDs) > 0 {
wheres = append(wheres, "target_user_id NOT IN ?")
placeholders = append(placeholders, blockedUserIDs)
}
// Don't show disabled or banned users.
var (
// Source user is banned (Requests, Ignored tabs)
bannedWhereRequest = `
EXISTS (
SELECT 1
FROM users
WHERE users.id = friends.source_user_id
AND users.status = 'active'
)
`
// Target user is banned (Friends, Sent tabs)
bannedWhereFriend = `
EXISTS (
SELECT 1
FROM users
WHERE users.id = friends.target_user_id
AND users.status = 'active'
)
`
)
if requests { if requests {
wheres = append(wheres, "target_user_id = ? AND approved = ? AND ignored IS NOT true") query = DB.Where(
placeholders = append(placeholders, user.ID, false) "target_user_id = ? AND approved = ? AND ignored IS NOT true",
user.ID, false,
// Don't show friend requests from currently banned/disabled users. )
wheres = append(wheres, bannedWhereRequest)
} else if sent { } else if sent {
wheres = append(wheres, "source_user_id = ? AND approved = ? AND ignored IS NOT true") query = DB.Where(
placeholders = append(placeholders, user.ID, false) "source_user_id = ? AND approved = ? AND ignored IS NOT true",
user.ID, false,
// Don't show friends who are currently banned/disabled. )
wheres = append(wheres, bannedWhereFriend)
} else if ignored { } else if ignored {
wheres = append(wheres, "target_user_id = ? AND approved = ? AND ignored = ?") query = DB.Where(
placeholders = append(placeholders, user.ID, false, true) "target_user_id = ? AND approved = ? AND ignored = ?",
user.ID, false, true,
// Don't show friend requests from currently banned/disabled users. )
wheres = append(wheres, bannedWhereRequest)
} else { } else {
wheres = append(wheres, "source_user_id = ? AND approved = ?") query = DB.Where(
placeholders = append(placeholders, user.ID, true) "source_user_id = ? AND approved = ?",
user.ID, true,
// Don't show friends who are currently banned/disabled. )
wheres = append(wheres, bannedWhereFriend)
} }
query = query.Where( query = query.Order(pager.Sort)
strings.Join(wheres, " AND "),
placeholders...,
).Order(pager.Sort)
query.Model(&Friend{}).Count(&pager.Total) query.Model(&Friend{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs) result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
if result.Error != nil { if result.Error != nil {
@ -492,27 +446,6 @@ func RemoveFriend(sourceUserID, targetUserID uint64) error {
return result.Error return result.Error
} }
// RevokeFriendPhotoNotifications removes notifications about newly uploaded friends photos
// that were sent to your former friends, when you remove their friendship.
//
// For example: if I unfriend you, all your past notifications that showed my friends-only photos should
// be revoked so that you can't see them anymore.
//
// Notifications about friend photos are revoked going in both directions.
func RevokeFriendPhotoNotifications(currentUser, other *User) error {
// Gather the IDs of all their friends-only photos to nuke notifications for.
allPhotoIDs, err := AllFriendsOnlyPhotoIDs(currentUser, other)
if err != nil {
return err
} else if len(allPhotoIDs) == 0 {
// Nothing to do.
return nil
}
log.Info("RevokeFriendPhotoNotifications(%s): forget about friend photo uploads for user %s on photo IDs: %v", currentUser.Username, other.Username, allPhotoIDs)
return RemoveSpecificNotificationBulk([]*User{currentUser, other}, NotificationNewPhoto, "photos", allPhotoIDs)
}
// Save photo. // Save photo.
func (f *Friend) Save() error { func (f *Friend) Save() error {
result := DB.Save(f) result := DB.Save(f)

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