Compare commits
191 Commits
face-detec
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
63471e2e9b | ||
|
ad0eb6e17c | ||
|
7ffcd6b3a8 | ||
|
b52d9df958 | ||
|
1b3e8cb250 | ||
|
e146c09850 | ||
|
704124157d | ||
|
b7bee75e1f | ||
|
cb37934935 | ||
|
26f9c4d71d | ||
|
2262edfe09 | ||
|
77a9d9a7fd | ||
|
8078ff8755 | ||
|
cbdabe791e | ||
|
7869ff83ba | ||
|
295183559d | ||
|
542d0bb300 | ||
|
c8d9cdbb3a | ||
|
106bcd377e | ||
|
f2e847922f | ||
|
3fdae1d8d7 | ||
|
0c7fc7e866 | ||
|
ab880148ad | ||
|
7aa1d512fc | ||
|
9d6c299fdd | ||
|
955ace1e91 | ||
|
0cd72a96ed | ||
|
944b2e28e9 | ||
|
9575041d1e | ||
|
066765d2dc | ||
|
ae84ddf449 | ||
|
7991320256 | ||
|
02487ba2f4 | ||
|
4b43071f28 | ||
|
2f31d678d0 | ||
|
8d9588b039 | ||
|
79ea384d40 | ||
|
463253dbb5 | ||
|
276eddfd8e | ||
|
2c7532434a | ||
|
b034b1ae6c | ||
|
c37d0298b0 | ||
|
617cd48308 | ||
|
242333d8b7 | ||
|
56a6190ce9 | ||
|
3921691319 | ||
|
e0e98d8df6 | ||
|
2cf4405ce0 | ||
|
def9f6ddcf | ||
|
36e48f6ce0 | ||
|
81719218e2 | ||
|
de52037fb0 | ||
|
85fd6ac5a2 | ||
|
90d0d10ee5 | ||
|
b12390563e | ||
|
05dc6c0e97 | ||
|
8a321eb8d2 | ||
|
28d1e284ab | ||
|
9570129bba | ||
|
1bf846e78b | ||
|
ef95d05453 | ||
|
5b0f8e7774 | ||
|
d11631c574 | ||
|
1f1341b0f7 | ||
|
bf87cf1a2e | ||
|
146a537ec4 | ||
|
a8dda91c3c | ||
|
2be90b4a81 | ||
|
e70ede301f | ||
|
1936bcde37 | ||
|
a00851a8b2 | ||
|
a6bd33fdf8 | ||
|
147a9162ba | ||
|
01c38c5c21 | ||
|
f1cf1bd958 | ||
|
b8be14ea8d | ||
|
6ca94cb926 | ||
|
5c5367c557 | ||
|
40b1f2f57a | ||
|
188e2e147c | ||
|
f3925c1095 | ||
|
ec9d2d6939 | ||
|
a314aab7ec | ||
|
dbeb5060e4 | ||
|
80cc5a97ee | ||
|
a0320714c4 | ||
|
2f997dfee0 | ||
|
1c01aad80f | ||
|
3203a487a5 | ||
|
9b72a95ca4 | ||
|
22b8cf0594 | ||
|
0db69983fe | ||
|
1134128a71 | ||
|
656710035b | ||
|
91a3cc27ba | ||
|
4f04323d5a | ||
|
a82e04b2f8 | ||
|
8754ed8592 | ||
|
72b6e2a616 | ||
|
a0f3083e15 | ||
|
88663d48a4 | ||
|
02ec0a9116 | ||
|
616f6ae76b | ||
|
6ac121b345 | ||
|
42aeb60853 | ||
|
6d46632270 | ||
4709e095f8 | |||
|
cc82fec108 | ||
|
6f5127dd56 | ||
|
2ac34eea79 | ||
|
5db1c03fd9 | ||
|
e71ca1fba3 | ||
|
97291c8721 | ||
|
f0e69f78da | ||
|
af76c251c6 | ||
|
8ed489c264 | ||
|
12a1adc270 | ||
|
a284aab026 | ||
|
b477ad5e73 | ||
|
c566e444c7 | ||
|
ed008a99e6 | ||
|
7c7d3a11e5 | ||
|
9db7343370 | ||
|
0f6dd58c54 | ||
|
20d04fc370 | ||
|
31ba987d62 | ||
|
fdf0aee5da | ||
|
198849eebc | ||
|
a00aec7488 | ||
|
2f352f8664 | ||
|
04f1c56809 | ||
|
106ca56198 | ||
|
ff2eb285eb | ||
|
382c6df96c | ||
|
19d06c183f | ||
|
f4721d65da | ||
|
e7f7f4d0d3 | ||
|
a0f41074bd | ||
|
4623cdca50 | ||
|
e947a005d9 | ||
|
32b054cacf | ||
|
6866bec972 | ||
|
7dc1ebd63f | ||
|
360ad41543 | ||
|
2126c5ab84 | ||
|
2f75059623 | ||
|
763b9e4404 | ||
|
268a177412 | ||
|
ddd33aad91 | ||
|
4ff7bc5d04 | ||
|
a47202d756 | ||
|
ff69b8f771 | ||
|
c8238c1749 | ||
|
58eaf53694 | ||
|
6a483929d2 | ||
|
fe2e43245b | ||
|
a669b58c55 | ||
|
ad59440b2b | ||
|
2d0fd25a08 | ||
|
535e96b491 | ||
|
2ab34a39a3 | ||
|
d4e3aa755b | ||
|
4f3f6de158 | ||
|
35258beb36 | ||
|
1c2982aec0 | ||
|
d623f0bc3c | ||
|
04a7616299 | ||
|
9c4ec85f8a | ||
|
cf6249c415 | ||
|
742a5fa1af | ||
|
be9276f4c0 | ||
|
80c4471017 | ||
|
28111585ef | ||
|
dd24aa1987 | ||
|
2820cf581e | ||
|
3142e0ce84 | ||
|
f4d176a538 | ||
85d2f4eee9 | |||
|
62d56d5924 | ||
|
3c0473c633 | ||
|
7ceb14053b | ||
|
7da650ffc4 | ||
|
588de52252 | ||
|
211c649f9a | ||
|
fedfbed4eb | ||
|
ef8abec7bf | ||
|
a9cc758624 | ||
|
20d9bf7768 | ||
|
f27b41a214 | ||
|
b4cd57c8c3 | ||
|
eed971d997 |
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,5 +1,6 @@
|
|||
/nonshy
|
||||
/web/static/photos
|
||||
/coldstorage
|
||||
database.sqlite
|
||||
settings.json
|
||||
pgdump.sql
|
||||
|
|
127
README.md
127
README.md
|
@ -20,20 +20,6 @@ The website can also run out of a local SQLite database which is convenient
|
|||
for local development. The production server runs on PostgreSQL and the
|
||||
web app is primarily designed for that.
|
||||
|
||||
### PostGIS Extension for PostgreSQL
|
||||
|
||||
For the "Who's Nearby" feature to work you will need a PostgreSQL
|
||||
database with the PostGIS geospatial extension installed. Usually
|
||||
it might be a matter of `dnf install postgis` and activating the
|
||||
extension on your nonshy database as your superuser (postgres):
|
||||
|
||||
```psql
|
||||
create extension postgis;
|
||||
```
|
||||
|
||||
If you get errors like "Type geography not found" from Postgres when
|
||||
running distance based searches, this is the likely culprit.
|
||||
|
||||
## Building the App
|
||||
|
||||
This app is written in Go: [go.dev](https://go.dev). You can probably
|
||||
|
@ -61,6 +47,96 @@ a database.
|
|||
For simple local development, just set `"UseSQLite": true` and the
|
||||
app will run with a SQLite database.
|
||||
|
||||
### Postgres is Highly Recommended
|
||||
|
||||
This website is intended to run under PostgreSQL and some of its
|
||||
features leverage Postgres specific extensions. For quick local
|
||||
development, SQLite will work fine but some website features will
|
||||
be disabled and error messages given. These include:
|
||||
|
||||
* Location features such as "Who's Nearby" (PostGIS extension)
|
||||
* "Newest" tab on the forums: to deduplicate comments by most recent
|
||||
thread depends on Postgres, SQLite will always show all latest
|
||||
comments without deduplication.
|
||||
|
||||
### PostGIS Extension for PostgreSQL
|
||||
|
||||
For the "Who's Nearby" feature to work you will need a PostgreSQL
|
||||
database with the PostGIS geospatial extension installed. Usually
|
||||
it might be a matter of `dnf install postgis` and activating the
|
||||
extension on your nonshy database as your superuser (postgres):
|
||||
|
||||
```psql
|
||||
create extension postgis;
|
||||
```
|
||||
|
||||
If you get errors like "Type geography not found" from Postgres when
|
||||
running distance based searches, this is the likely culprit.
|
||||
|
||||
### Signed Photo URLs (NGINX)
|
||||
|
||||
The website supports "signed photo" URLs that can help protect the direct
|
||||
links to user photos (their /static/photos/*.jpg paths) to ensure only
|
||||
logged-in and authorized users are able to access those links.
|
||||
|
||||
This feature is not enabled (enforcing) by default, as it relies on
|
||||
cooperation with the NGINX reverse proxy server
|
||||
(module ngx_http_auth_request).
|
||||
|
||||
In your NGINX config, set your /static/ path to leverage NGINX auth_request
|
||||
like so:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
# your boilerplate server info (SSL, etc.) - not relevant to this example.
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
|
||||
# Relevant: setting the /static/ URL on NGINX to be an alias to your local
|
||||
# nonshy static folder on disk. In this example, the git clone for the
|
||||
# website was at /home/www-user/git/nonshy/website, so that ./web/static/
|
||||
# is the local path where static files (e.g., photos) are uploaded.
|
||||
location /static/ {
|
||||
# Important: auth_request tells NGINX to do subrequest authentication
|
||||
# on requests into the /static/ URI of your website.
|
||||
auth_request /static-auth;
|
||||
|
||||
# standard NGINX alias commands.
|
||||
alias /home/www-user/git/nonshy/website/web/static/;
|
||||
autoindex off;
|
||||
}
|
||||
|
||||
# Configure the internal subrequest auth path.
|
||||
# Note: the path "/static-auth" can be anything you want.
|
||||
location = /static-auth {
|
||||
internal; # this is an internal route for NGINX only, not public
|
||||
|
||||
# Proxy to the /v1/auth/static URL on the web app.
|
||||
# This line assumes the website runs on localhost:8080.
|
||||
proxy_pass http://localhost:8080/v1/auth/static;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
|
||||
# Important: the X-Original-URI header tells the web app what the
|
||||
# original path (e.g. /static/photos/*) was, so the web app knows
|
||||
# which sub-URL to enforce authentication on.
|
||||
proxy_set_header X-Original-URI $request_uri;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When your NGINX config is set up like the above, you can edit the
|
||||
settings.json to mark SignedPhoto/Enabled=true, and restart the
|
||||
website. Be sure to test it!
|
||||
|
||||
On a photo gallery view, all image URLs under /static/photos/ should
|
||||
come with a ?jwt= parameter, and the image should load for the current
|
||||
user. The JWT token is valid for 30 seconds after which the direct link
|
||||
to the image should expire and give a 403 Forbidden response.
|
||||
|
||||
When this feature is NOT enabled/not enforcing: the jwt= parameter is
|
||||
still generated on photo URLs but is not enforced by the web app.
|
||||
|
||||
## Usage
|
||||
|
||||
The `nonshy` binary has sub-commands to either run the web server
|
||||
|
@ -126,26 +202,15 @@ the web app by using the admin controls on their profile page.
|
|||
templates, issue redirects, error pages, ...
|
||||
* `pkg/utility`: miscellaneous useful functions for the app.
|
||||
|
||||
## Cron API Endpoints
|
||||
## Cron workers
|
||||
|
||||
In settings.json get or configure the CronAPIKey (a UUID4 value is good and
|
||||
the app generates a fresh one by default). The following are the cron API
|
||||
endpoints that you may want to configure to run periodic maintenance tasks
|
||||
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:
|
||||
You can schedule the `nonshy vacuum` command in your crontab. This command
|
||||
will check and clean up the database for things such as: orphaned comment
|
||||
photos (where somebody uploaded a photo to post on the forum, but then didn't
|
||||
finish creating their post).
|
||||
|
||||
```cron
|
||||
0 2 * * * curl "http://localhost:8080/v1/comment-photos/remove-orphaned?apiKey=X"
|
||||
0 2 * * * cd /home/nonshy/git/website && ./nonshy vacuum
|
||||
```
|
||||
|
||||
## License
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
|
||||
nonshy "code.nonshy.com/nonshy/website/pkg"
|
||||
"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/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/models/backfill"
|
||||
|
@ -169,6 +170,81 @@ 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",
|
||||
Usage: "One-off maintenance tasks and data backfills for database migrations",
|
||||
|
@ -188,6 +264,35 @@ func main() {
|
|||
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"))
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
70
docs/Cold Storage.md
Normal file
70
docs/Cold Storage.md
Normal file
|
@ -0,0 +1,70 @@
|
|||
# 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.
|
82
go.mod
82
go.mod
|
@ -1,63 +1,65 @@
|
|||
module code.nonshy.com/nonshy/website
|
||||
|
||||
go 1.18
|
||||
go 1.22
|
||||
|
||||
toolchain go1.22.0
|
||||
|
||||
require (
|
||||
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/google/uuid v1.3.0
|
||||
github.com/urfave/cli/v2 v2.11.1
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
|
||||
gorm.io/driver/postgres v1.3.8
|
||||
gorm.io/driver/sqlite v1.3.6
|
||||
gorm.io/gorm v1.23.8
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.26
|
||||
github.com/oschwald/geoip2-golang v1.9.0
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629
|
||||
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 (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||
github.com/boombuler/barcode v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f // 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/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgproto3/v2 v2.3.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
|
||||
github.com/jackc/pgtype v1.11.0 // indirect
|
||||
github.com/jackc/pgx/v4 v4.16.1 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
|
||||
github.com/jackc/pgx/v5 v5.5.3 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.14 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.19 // indirect
|
||||
github.com/oschwald/geoip2-golang v1.9.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/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.12.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||
github.com/russross/blackfriday v1.6.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||
github.com/sergi/go-diff v1.2.0 // indirect
|
||||
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 // indirect
|
||||
github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480 // indirect
|
||||
github.com/shurcooL/highlight_go v0.0.0-20191220051317-782971ddf21b // indirect
|
||||
github.com/shurcooL/octicon v0.0.0-20191102190552-cbb32d6a785c // indirect
|
||||
github.com/sergi/go-diff v1.3.1 // indirect
|
||||
github.com/shurcooL/go v0.0.0-20230706063926-5fe729b41b3a // indirect
|
||||
github.com/shurcooL/go-goon v1.0.0 // indirect
|
||||
github.com/shurcooL/highlight_diff v0.0.0-20230708024848-22f825814995 // indirect
|
||||
github.com/shurcooL/highlight_go v0.0.0-20230708025100-33e05792540a // indirect
|
||||
github.com/shurcooL/octicon v0.0.0-20230705024016-66bff059edb8 // indirect
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d // indirect
|
||||
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect
|
||||
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 // indirect
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect
|
||||
golang.org/x/sys v0.9.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
|
||||
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect
|
||||
golang.org/x/net v0.21.0 // indirect
|
||||
golang.org/x/sync v0.6.0 // indirect
|
||||
golang.org/x/sys v0.17.0 // indirect
|
||||
golang.org/x/term v0.17.0 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect
|
||||
)
|
||||
|
|
305
go.sum
305
go.sum
|
@ -1,20 +1,18 @@
|
|||
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=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||
github.com/SherClockHolmes/webpush-go v1.3.0 h1:CAu3FvEE9QS4drc3iKNgpBWFfGqNthKlZhp5QpYnu6k=
|
||||
github.com/SherClockHolmes/webpush-go v1.3.0/go.mod h1:AxRHmJuYwKGG1PVgYzToik1lphQvDnqFYDqimHvwhIw=
|
||||
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/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/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
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/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
|
||||
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
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/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=
|
||||
|
@ -22,253 +20,162 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1
|
|||
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/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc=
|
||||
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/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
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-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
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/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
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/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
|
||||
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
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/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
|
||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.5.3 h1:Ces6/M3wbDXYpM8JyyPD57ivTtJACFZJd885pdIaV2s=
|
||||
github.com/jackc/pgx/v5 v5.5.3/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
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/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/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.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.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
|
||||
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
|
||||
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/maxminddb-golang v1.11.0 h1:aSXMqYR/EPNjGE8epgqwDay+P30hCBZIveY0WZbAWh0=
|
||||
github.com/oschwald/maxminddb-golang v1.11.0/go.mod h1:YmVI+H0zh3ySFR3w+oz8PCfglAFj3PuCmui13+P9zDg=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs=
|
||||
github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
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/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
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/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
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/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/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
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/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
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/highlight_diff v0.0.0-20181222201841-111da2e7d480 h1:KaKXZldeYH73dpQL+Nr38j1r5BgpAYQjYvENOUpIZDQ=
|
||||
github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU=
|
||||
github.com/shurcooL/highlight_go v0.0.0-20191220051317-782971ddf21b h1:rBIwpb5ggtqf0uZZY5BPs1sL7njUMM7I8qD2jiou70E=
|
||||
github.com/shurcooL/highlight_go v0.0.0-20191220051317-782971ddf21b/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag=
|
||||
github.com/shurcooL/octicon v0.0.0-20191102190552-cbb32d6a785c h1:p3w+lTqXulfa3aDeycxmcLJDNxyUB89gf2/XqqK3eO0=
|
||||
github.com/shurcooL/octicon v0.0.0-20191102190552-cbb32d6a785c/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ=
|
||||
github.com/shurcooL/go v0.0.0-20230706063926-5fe729b41b3a h1:ZHfoO7ZJhws9NU1kzZhStUnnVQiPtDe1PzpUnc6HirU=
|
||||
github.com/shurcooL/go v0.0.0-20230706063926-5fe729b41b3a/go.mod h1:DNrlr0AR9NsHD/aoc2pPeu4uSBZ/71yCHkR42yrzW3M=
|
||||
github.com/shurcooL/go-goon v1.0.0 h1:BCQPvxGkHHJ4WpBO4m/9FXbITVIsvAm/T66cCcCGI7E=
|
||||
github.com/shurcooL/go-goon v1.0.0/go.mod h1:2wTHMsGo7qnpmqA8ADYZtP4I1DD94JpXGQ3Dxq2YQ5w=
|
||||
github.com/shurcooL/highlight_diff v0.0.0-20230708024848-22f825814995 h1:/6Fa0HAouqks/nlr3C3sv7KNDqutP3CM/MYz225uO28=
|
||||
github.com/shurcooL/highlight_diff v0.0.0-20230708024848-22f825814995/go.mod h1:eqklBUMsamqZbxXhhr6GafgswFTa5Aq12VQ0I2lnCR8=
|
||||
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/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/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/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
|
||||
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.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.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/go.mod h1:fSIW/szJHsRts/4U8wlMPhs+YqJC+7NYR+Qqb1uJVpA=
|
||||
github.com/urfave/cli/v2 v2.11.1 h1:UKK6SP7fV3eKOefbS87iT9YHefv7iB/53ih6e+GNAsE=
|
||||
github.com/urfave/cli/v2 v2.11.1/go.mod h1:f8iq5LtQ/bLxafbdBSLPPNsgaW0l/2fYYEHhAyPlwvo=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||
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=
|
||||
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
|
||||
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
||||
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI=
|
||||
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
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-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 h1:/eM0PCrQI2xd471rI+snWuu251/+/jpBpZqir2mPdnU=
|
||||
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
|
||||
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/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
|
||||
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
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-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.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/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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-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-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-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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
|
||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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.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.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.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.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-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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
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/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
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/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
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/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/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
||||
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
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.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
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=
|
||||
gorm.io/driver/postgres v1.3.8 h1:8bEphSAB69t3odsCR4NDzt581iZEWQuRM27Cg6KgfPY=
|
||||
gorm.io/driver/postgres v1.3.8/go.mod h1:qB98Aj6AhRO/oyu/jmZsi/YM9g6UzVCjMxO/6frFvcA=
|
||||
gorm.io/driver/sqlite v1.3.6 h1:Fi8xNYCUplOqWiPa3/GuCeowRNBRGTf62DEmhMDHeQQ=
|
||||
gorm.io/driver/sqlite v1.3.6/go.mod h1:Sg1/pvnKtbQ7jLXxfZa+jSHvoX8hoZA8cn4xllOMTgE=
|
||||
gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.23.6/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.23.8 h1:h8sGJ+biDgBA1AD1Ha9gFCx7h8npU7AsLdlkX0n2TpE=
|
||||
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=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.6 h1:ydr9xEd5YAM0vxVDY0X139dyzNz10spDiDlC7+ibLeU=
|
||||
gorm.io/driver/postgres v1.5.6/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
|
||||
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
|
||||
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
|
||||
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
|
|
224
pkg/chat/chat_api.go
Normal file
224
pkg/chat/chat_api.go
Normal file
|
@ -0,0 +1,224 @@
|
|||
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
|
||||
}
|
37
pkg/config/admin_labels.go
Normal file
37
pkg/config/admin_labels.go
Normal file
|
@ -0,0 +1,37 @@
|
|||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
// Admin Labels.
|
||||
const (
|
||||
// Admin Labels for Photos
|
||||
AdminLabelPhotoNonExplicit = "non-explicit"
|
||||
AdminLabelPhotoForceExplicit = "force-explicit"
|
||||
)
|
||||
|
||||
var (
|
||||
AdminLabelPhotoOptions = []ChecklistOption{
|
||||
{
|
||||
Value: AdminLabelPhotoNonExplicit,
|
||||
Label: "This is not an Explicit photo",
|
||||
Help: "Hide the prompt 'Should this photo be marked as explicit?' as this photo does not NEED to be Explicit. " +
|
||||
"Note: the owner of this photo MAY still mark it explicit if they want to.",
|
||||
},
|
||||
{
|
||||
Value: AdminLabelPhotoForceExplicit,
|
||||
Label: "Force this photo to be marked as Explicit",
|
||||
Help: "Enabling this option will force the Explicit tag to stay on, and not allow the user to remove it.",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// HasAdminLabel checks if a comma-separated set of admin labels contains the label.
|
||||
func HasAdminLabel(needle string, haystack string) bool {
|
||||
labels := strings.Split(haystack, ",")
|
||||
for _, label := range labels {
|
||||
if strings.TrimSpace(label) == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
|
@ -6,11 +6,9 @@ const (
|
|||
// - Chat: have operator controls in the chat room
|
||||
// - Forum: ability to edit and delete user posts
|
||||
// - Photo: omniscient view of all gallery photos, can edit/delete photos
|
||||
// - Inner circle: ability to remove users from it
|
||||
ScopeChatModerator = "social.moderator.chat"
|
||||
ScopeForumModerator = "social.moderator.forum"
|
||||
ScopePhotoModerator = "social.moderator.photo"
|
||||
ScopeCircleModerator = "social.moderator.inner-circle"
|
||||
ScopeChatModerator = "social.moderator.chat"
|
||||
ScopeForumModerator = "social.moderator.forum"
|
||||
ScopePhotoModerator = "social.moderator.photo"
|
||||
|
||||
// Certification photo management
|
||||
// - Approve: ability to respond to pending certification pics
|
||||
|
@ -32,21 +30,53 @@ const (
|
|||
// - Impersonate: ability to log in as a user account
|
||||
// - Ban: ability to ban/unban users
|
||||
// - Delete: ability to delete user accounts
|
||||
ScopeUserCreate = "admin.user.create"
|
||||
ScopeUserInsight = "admin.user.insights"
|
||||
ScopeUserImpersonate = "admin.user.impersonate"
|
||||
ScopeUserBan = "admin.user.ban"
|
||||
ScopeUserPromote = "admin.user.promote"
|
||||
ScopeUserPassword = "admin.user.password"
|
||||
ScopeUserDelete = "admin.user.delete"
|
||||
ScopeUserPromote = "admin.user.promote"
|
||||
|
||||
// Other admin views
|
||||
ScopeFeedbackAndReports = "admin.feedback"
|
||||
ScopeChangeLog = "admin.changelog"
|
||||
ScopeUserNotes = "admin.user.notes"
|
||||
|
||||
// Admins with this scope can not be blocked by users.
|
||||
ScopeUnblockable = "admin.unblockable"
|
||||
|
||||
// Special scope to mark an admin automagically in the Inner Circle
|
||||
ScopeIsInnerCircle = "admin.override.inner-circle"
|
||||
// The global wildcard scope gets all available permissions.
|
||||
ScopeSuperuser = "*"
|
||||
)
|
||||
|
||||
// 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.
|
||||
const QuantityAdminScopes = 16
|
||||
const QuantityAdminScopes = 20
|
||||
|
||||
// The specially named Superusers group.
|
||||
const AdminGroupSuperusers = "Superusers"
|
||||
|
@ -57,18 +87,26 @@ func ListAdminScopes() []string {
|
|||
ScopeChatModerator,
|
||||
ScopeForumModerator,
|
||||
ScopePhotoModerator,
|
||||
ScopeCircleModerator,
|
||||
ScopeCertificationApprove,
|
||||
ScopeCertificationList,
|
||||
ScopeCertificationView,
|
||||
ScopeForumAdmin,
|
||||
ScopeAdminScopeAdmin,
|
||||
ScopeMaintenance,
|
||||
ScopeUserCreate,
|
||||
ScopeUserInsight,
|
||||
ScopeUserImpersonate,
|
||||
ScopeUserBan,
|
||||
ScopeUserPassword,
|
||||
ScopeUserDelete,
|
||||
ScopeUserPromote,
|
||||
ScopeFeedbackAndReports,
|
||||
ScopeChangeLog,
|
||||
ScopeUserNotes,
|
||||
ScopeUnblockable,
|
||||
ScopeIsInnerCircle,
|
||||
}
|
||||
}
|
||||
|
||||
func AdminScopeDescription(scope string) string {
|
||||
return AdminScopeDescriptions[scope]
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import (
|
|||
// returned by the scope list function.
|
||||
func TestAdminScopesCount(t *testing.T) {
|
||||
var scopes = config.ListAdminScopes()
|
||||
if len(scopes) != config.QuantityAdminScopes {
|
||||
if len(scopes) != config.QuantityAdminScopes || len(scopes) != len(config.AdminScopeDescriptions) {
|
||||
t.Errorf(
|
||||
"The list of scopes returned by ListAdminScopes doesn't match the expected count. "+
|
||||
"Expected %d, got %d",
|
||||
|
|
|
@ -25,6 +25,10 @@ const (
|
|||
PhotoDiskPath = "./web/static/photos"
|
||||
)
|
||||
|
||||
// PhotoURLRegexp describes an image path under "/static/photos" that can be parsed from Markdown or HTML input.
|
||||
// It is used by e.g. the ReSignURLs function - if you move image URLs to a CDN this may need updating.
|
||||
var PhotoURLRegexp = regexp.MustCompile(`(?:['"])(/static/photos/[^'"\s?]+(?:\?[^'"\s]*)?)(?:['"]|[^'"\s]*)`)
|
||||
|
||||
// Security
|
||||
const (
|
||||
BcryptCost = 14
|
||||
|
@ -38,6 +42,11 @@ const (
|
|||
|
||||
TwoFactorBackupCodeCount = 12
|
||||
TwoFactorBackupCodeLength = 8 // characters a-z0-9
|
||||
|
||||
// Signed URLs for static photo authentication.
|
||||
SignedPhotoJWTExpires = 30 * time.Second // Regular, per-user, short window
|
||||
SignedPublicAvatarJWTExpires = 7 * 24 * time.Hour // Widely public, e.g. chat room
|
||||
SignedPublicAvatarUsername = "@" // JWT 'username' for widely public JWT
|
||||
)
|
||||
|
||||
// Authentication
|
||||
|
@ -51,6 +60,11 @@ const (
|
|||
ChangeEmailRedisKey = "change-email/%s"
|
||||
SignupTokenExpires = 24 * time.Hour // used for all tokens so far
|
||||
|
||||
// How to rate limit same types of emails being delivered, e.g.
|
||||
// signups, cert approvals (double post), etc.
|
||||
EmailDebounceDefault = 24 * time.Hour // default debounce per type of email
|
||||
EmailDebounceResetPassword = 4 * time.Hour // "forgot password" emails debounce
|
||||
|
||||
// Rate limits
|
||||
RateLimitRedisKey = "rate-limit/%s/%s" // namespace, id
|
||||
LoginRateLimitWindow = 1 * time.Hour
|
||||
|
@ -66,15 +80,25 @@ const (
|
|||
ContactRateLimitCooldownAt = 1
|
||||
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.
|
||||
LastLoginAtCooldown = time.Hour
|
||||
|
||||
// Chat room status refresh interval.
|
||||
ChatStatusRefreshInterval = 30 * time.Second
|
||||
|
||||
// Cache TTL for the demographics page.
|
||||
DemographicsCacheTTL = time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
UsernameRegexp = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
|
||||
UsernameRegexp = regexp.MustCompile(`^[a-z0-9_.-]{3,32}$`)
|
||||
ReservedUsernames = []string{
|
||||
"admin",
|
||||
"admins",
|
||||
|
@ -94,20 +118,24 @@ var (
|
|||
const (
|
||||
MaxPhotoWidth = 1280
|
||||
ProfilePhotoWidth = 512
|
||||
AltTextMaxLength = 5000
|
||||
|
||||
// Quotas for uploaded photos.
|
||||
PhotoQuotaUncertified = 6
|
||||
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.
|
||||
// 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
|
||||
// pictures can be posted per day.
|
||||
SiteGalleryRateLimitMax = 5
|
||||
SiteGalleryRateLimitInterval = 24 * time.Hour
|
||||
|
||||
// Only ++ the Views count per user per photo within a small
|
||||
// window of time - if a user keeps reloading the same photo
|
||||
// rapidly it does not increment the view counter more.
|
||||
PhotoViewDebounceRedisKey = "debounce-view/user=%d/photoid=%d"
|
||||
PhotoViewDebounceCooldown = 1 * time.Hour
|
||||
)
|
||||
|
||||
// Forum settings
|
||||
|
@ -117,6 +145,27 @@ const (
|
|||
// rapidly it does not increment the view counter more.
|
||||
ThreadViewDebounceRedisKey = "debounce-view/user=%d/thr=%d"
|
||||
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
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package config
|
||||
|
||||
import "regexp"
|
||||
|
||||
// Various hard-coded enums such as choice of gender, sexuality, relationship status etc.
|
||||
var (
|
||||
MaritalStatus = []string{
|
||||
|
@ -32,6 +34,8 @@ var (
|
|||
"Gay",
|
||||
"Bisexual",
|
||||
"Bicurious",
|
||||
"Pansexual",
|
||||
"Asexual",
|
||||
}
|
||||
|
||||
HereFor = []string{
|
||||
|
@ -64,12 +68,18 @@ var (
|
|||
"music_movies",
|
||||
"hide_age",
|
||||
}
|
||||
EssayProfileFields = []string{
|
||||
"about_me",
|
||||
"interests",
|
||||
"music_movies",
|
||||
}
|
||||
|
||||
// Site preference names (stored in ProfileField table)
|
||||
SitePreferenceFields = []string{
|
||||
"dm_privacy",
|
||||
"blur_explicit",
|
||||
"site_gallery_default", // default view on site gallery (friends-only or all certified?)
|
||||
"chat_moderation_rules",
|
||||
}
|
||||
|
||||
// Choices for the Contact Us subject
|
||||
|
@ -90,6 +100,8 @@ var (
|
|||
{"report.photo", "Report a problematic photo"},
|
||||
{"report.message", "Report a direct message conversation"},
|
||||
{"report.comment", "Report a forum post or comment"},
|
||||
{"report.forum", "Report a forum or community"},
|
||||
{"forum.adopt", "Adopt a forum"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -97,12 +109,41 @@ var (
|
|||
// Default forum categories for forum landing page.
|
||||
ForumCategories = []string{
|
||||
"Rules and Announcements",
|
||||
"The Inner Circle",
|
||||
"Nudists",
|
||||
"Exhibitionists",
|
||||
"Photo Boards",
|
||||
"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.
|
||||
|
@ -117,6 +158,13 @@ type Option struct {
|
|||
Label string
|
||||
}
|
||||
|
||||
// ChecklistOption for checkbox-lists.
|
||||
type ChecklistOption struct {
|
||||
Value string
|
||||
Label string
|
||||
Help string
|
||||
}
|
||||
|
||||
// NotificationOptout field values (stored in user ProfileField table)
|
||||
const (
|
||||
NotificationOptOutFriendPhotos = "notif_optout_friends_photos"
|
||||
|
@ -127,6 +175,10 @@ const (
|
|||
NotificationOptOutSubscriptions = "notif_optout_subscriptions"
|
||||
NotificationOptOutFriendRequestAccepted = "notif_optout_friend_request_accepted"
|
||||
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)
|
||||
|
@ -140,3 +192,9 @@ var NotificationOptOutFields = []string{
|
|||
NotificationOptOutFriendRequestAccepted,
|
||||
NotificationOptOutPrivateGrant,
|
||||
}
|
||||
|
||||
// Push Notification opt-outs (stored in ProfileField table)
|
||||
var PushNotificationOptOutFields = []string{
|
||||
PushNotificationOptOutMessage,
|
||||
PushNotificationOptOutFriends,
|
||||
}
|
||||
|
|
|
@ -15,13 +15,16 @@ var (
|
|||
PageSizePrivatePhotoGrantees = 12
|
||||
PageSizeAdminCertification = 20
|
||||
PageSizeAdminFeedback = 20
|
||||
PageSizeAdminFeedbackNotesPage = 5 // feedback on User Notes page
|
||||
PageSizeAdminFeedbackNotesPage = 5 // feedback on User Notes page
|
||||
PageSizeChangeLog = 20
|
||||
PageSizeAdminUserNotes = 10 // other users' notes
|
||||
PageSizeSiteGallery = 16
|
||||
PageSizeUserGallery = 16
|
||||
PageSizeInboxList = 20 // sidebar list
|
||||
PageSizeInboxThread = 10 // conversation view
|
||||
PageSizeInboxList = 20 // sidebar list
|
||||
PageSizeInboxThread = 10 // conversation view
|
||||
PageSizeBrowseForums = 20
|
||||
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
|
||||
PageSizeForumAdmin = 20
|
||||
PageSizeDashboardNotifications = 50
|
||||
|
|
|
@ -4,17 +4,18 @@ import (
|
|||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/encryption/coldstorage"
|
||||
"code.nonshy.com/nonshy/website/pkg/encryption/keygen"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Version of the config format - when new fields are added, it will attempt
|
||||
// to write the settings.toml to disk so new defaults populate.
|
||||
var currentVersion = 2
|
||||
var currentVersion = 5
|
||||
|
||||
// Current loaded settings.json
|
||||
var Current = DefaultVariable()
|
||||
|
@ -31,6 +32,9 @@ type Variable struct {
|
|||
BareRTC BareRTC
|
||||
Maintenance Maintenance
|
||||
Encryption Encryption
|
||||
SignedPhoto SignedPhoto
|
||||
WebPush WebPush
|
||||
Turnstile Turnstile
|
||||
UseXForwardedFor bool
|
||||
}
|
||||
|
||||
|
@ -62,7 +66,7 @@ func LoadSettings() {
|
|||
|
||||
if _, err := os.Stat(SettingsPath); !os.IsNotExist(err) {
|
||||
log.Info("Loading settings from %s", SettingsPath)
|
||||
content, err := ioutil.ReadFile(SettingsPath)
|
||||
content, err := os.ReadFile(SettingsPath)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("LoadSettings: couldn't read settings.json: %s", err))
|
||||
}
|
||||
|
@ -97,6 +101,38 @@ func LoadSettings() {
|
|||
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.
|
||||
if Current.Version != currentVersion || writeSettings {
|
||||
log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.")
|
||||
|
@ -119,7 +155,7 @@ func WriteSettings() error {
|
|||
panic(fmt.Sprintf("WriteSettings: couldn't marshal settings: %s", err))
|
||||
}
|
||||
|
||||
return ioutil.WriteFile(SettingsPath, buf.Bytes(), 0600)
|
||||
return os.WriteFile(SettingsPath, buf.Bytes(), 0600)
|
||||
}
|
||||
|
||||
// Mail settings.
|
||||
|
@ -163,5 +199,25 @@ type Maintenance struct {
|
|||
|
||||
// Encryption settings.
|
||||
type Encryption struct {
|
||||
AESKey []byte
|
||||
AESKey []byte
|
||||
ColdStorageRSAPublicKey []byte
|
||||
}
|
||||
|
||||
// SignedPhoto settings.
|
||||
type SignedPhoto struct {
|
||||
Enabled bool
|
||||
JWTSecret string
|
||||
}
|
||||
|
||||
// WebPush settings.
|
||||
type WebPush struct {
|
||||
VAPIDPublicKey string
|
||||
VAPIDPrivateKey string
|
||||
}
|
||||
|
||||
// Turnstile (Cloudflare CAPTCHA) settings.
|
||||
type Turnstile struct {
|
||||
Enabled bool
|
||||
SiteKey string
|
||||
SecretKey string
|
||||
}
|
||||
|
|
|
@ -43,14 +43,17 @@ func Dashboard() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Parse notification filters.
|
||||
nf := models.NewNotificationFilterFromForm(r)
|
||||
|
||||
// Get our notifications.
|
||||
pager := &models.Pagination{
|
||||
Page: 1,
|
||||
PerPage: config.PageSizeDashboardNotifications,
|
||||
Sort: "created_at desc",
|
||||
Sort: "read, created_at desc",
|
||||
}
|
||||
pager.ParsePage(r)
|
||||
notifs, err := models.PaginateNotifications(currentUser, pager)
|
||||
notifs, err := models.PaginateNotifications(currentUser, nf, pager)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't get your notifications: %s", err)
|
||||
}
|
||||
|
@ -86,6 +89,7 @@ func Dashboard() http.HandlerFunc {
|
|||
var vars = map[string]interface{}{
|
||||
"Notifications": notifs,
|
||||
"NotifMap": notifMap,
|
||||
"Filters": nf,
|
||||
"Pager": pager,
|
||||
|
||||
// Show a warning to 'restricted' profiles who are especially private.
|
||||
|
|
|
@ -4,6 +4,8 @@ import (
|
|||
"net/http"
|
||||
"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/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
|
@ -41,6 +43,14 @@ func Deactivate() http.HandlerFunc {
|
|||
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.")
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -78,5 +88,8 @@ func Reactivate() http.HandlerFunc {
|
|||
|
||||
session.Flash(w, r, "Welcome back! Your account has been reactivated.")
|
||||
templates.Redirect(w, "/")
|
||||
|
||||
// Log the change.
|
||||
models.LogEvent(currentUser, nil, models.ChangeLogLifecycle, "users", currentUser.ID, "Reactivated their account.")
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
|
@ -40,6 +44,14 @@ func Delete() http.HandlerFunc {
|
|||
session.LogoutUser(w, r)
|
||||
session.Flash(w, r, "Your account has been deleted.")
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,6 @@ package account
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
|
@ -10,18 +9,12 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
var UserFriendsRegexp = regexp.MustCompile(`^/friends/u/([^@]+?)$`)
|
||||
|
||||
// User friends page (/friends/u/username)
|
||||
func UserFriends() http.HandlerFunc {
|
||||
tmpl := templates.Must("account/friends.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse the username out of the URL parameters.
|
||||
var username string
|
||||
m := UserFriendsRegexp.FindStringSubmatch(r.URL.Path)
|
||||
if m != nil {
|
||||
username = m[1]
|
||||
}
|
||||
var username = r.PathValue("username")
|
||||
|
||||
// Find this user.
|
||||
user, err := models.FindUser(username)
|
||||
|
|
|
@ -1,168 +0,0 @@
|
|||
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
|
||||
}
|
||||
})
|
||||
}
|
|
@ -38,10 +38,23 @@ func Login() http.HandlerFunc {
|
|||
CooldownAt: config.LoginRateLimitCooldownAt,
|
||||
Cooldown: config.LoginRateLimitCooldown,
|
||||
}
|
||||
var takebackDeferredError bool
|
||||
if err := limiter.Ping(); err != nil {
|
||||
session.FlashError(w, r, err.Error())
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
// Is it a deferred error? Flash it at the end of the request but continue
|
||||
// to process this login attempt as normal.
|
||||
if ratelimit.IsDeferredError(err) {
|
||||
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.
|
||||
|
@ -124,6 +137,9 @@ func Login() http.HandlerFunc {
|
|||
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.
|
||||
session.Flash(w, r, "Login successful.")
|
||||
if strings.HasPrefix(next, "/") {
|
||||
|
|
|
@ -3,8 +3,9 @@ package account
|
|||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/middleware"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
|
@ -13,18 +14,12 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/worker"
|
||||
)
|
||||
|
||||
var ProfileRegexp = regexp.MustCompile(`^/u/([^@]+?)$`)
|
||||
|
||||
// User profile page (/u/username)
|
||||
func Profile() http.HandlerFunc {
|
||||
tmpl := templates.Must("account/profile.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse the username out of the URL parameters.
|
||||
var username string
|
||||
m := ProfileRegexp.FindStringSubmatch(r.URL.Path)
|
||||
if m != nil {
|
||||
username = m[1]
|
||||
}
|
||||
var username = r.PathValue("username")
|
||||
|
||||
// Find this user.
|
||||
user, err := models.FindUser(username)
|
||||
|
@ -45,7 +40,8 @@ func Profile() http.HandlerFunc {
|
|||
}
|
||||
|
||||
vars := map[string]interface{}{
|
||||
"User": user,
|
||||
"User": user,
|
||||
"IsExternalView": true,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
@ -77,23 +73,18 @@ func Profile() http.HandlerFunc {
|
|||
// Inject relationship booleans for profile picture display.
|
||||
models.SetUserRelationships(currentUser, []*models.User{user})
|
||||
|
||||
// Admin user can always see the profile pic - but only on this page. Other avatar displays
|
||||
// will show the yellow or pink shy.png if the admin is not friends or not granted.
|
||||
if currentUser.IsAdmin {
|
||||
// Admin user (photo moderator) can always see the profile pic - but only on this page.
|
||||
// Other avatar displays will show the yellow or pink shy.png if the admin is not friends or not granted.
|
||||
if currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||
user.UserRelationship.IsFriend = true
|
||||
user.UserRelationship.IsPrivateGranted = true
|
||||
}
|
||||
|
||||
var isSelf = currentUser.ID == user.ID
|
||||
|
||||
// Banned or disabled? Only admin can view then.
|
||||
if user.Status != models.UserStatusActive && !currentUser.IsAdmin {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Is either one blocking?
|
||||
if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
|
||||
// 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
|
||||
}
|
||||
|
@ -113,27 +104,32 @@ func Profile() http.HandlerFunc {
|
|||
log.Error("WhoLikes(user %d): %s", user.ID, err)
|
||||
}
|
||||
|
||||
// Chat Moderation Rule: count of rules applied to the user, for admin view.
|
||||
var chatModerationRules int
|
||||
if currentUser.HasAdminScope(config.ScopeChatModerator) {
|
||||
if rules := user.GetProfileField("chat_moderation_rules"); len(rules) > 0 {
|
||||
chatModerationRules = len(strings.Split(rules, ","))
|
||||
}
|
||||
}
|
||||
|
||||
vars := map[string]interface{}{
|
||||
"User": user,
|
||||
"LikeMap": likeMap,
|
||||
"IsFriend": isFriend,
|
||||
"IsPrivate": isPrivate,
|
||||
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
|
||||
"NoteCount": models.CountNotesAboutUser(currentUser, user),
|
||||
"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),
|
||||
"OnChat": worker.GetChatStatistics().IsOnline(user.Username),
|
||||
"User": user,
|
||||
"LikeMap": likeMap,
|
||||
"IsFriend": isFriend,
|
||||
"IsPrivate": isPrivate,
|
||||
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
|
||||
"NoteCount": models.CountNotesAboutUser(currentUser, user),
|
||||
"FriendCount": models.CountFriends(user.ID),
|
||||
"OnChat": worker.GetChatStatistics().IsOnline(user.Username),
|
||||
|
||||
// Details on who likes their profile page.
|
||||
"LikeExample": likeExample,
|
||||
"LikeRemainder": likeRemainder,
|
||||
"LikeTableName": "users",
|
||||
"LikeTableID": user.ID,
|
||||
|
||||
// Admin numbers.
|
||||
"NumChatModerationRules": chatModerationRules,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
|
|
|
@ -135,17 +135,25 @@ func ForgotPassword() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Email them their reset link.
|
||||
if err := mail.Send(mail.Message{
|
||||
To: user.Email,
|
||||
Subject: "Reset your forgotten password",
|
||||
Template: "email/reset_password.html",
|
||||
Data: map[string]interface{}{
|
||||
"Username": user.Username,
|
||||
"URL": config.Current.BaseURL + "/forgot-password?token=" + token.Token,
|
||||
},
|
||||
}); err != nil {
|
||||
session.FlashError(w, r, "Error sending an email: %s", err)
|
||||
// Email them their reset link -- if not banned.
|
||||
if !user.IsBanned() {
|
||||
if err := mail.LockSending("reset_password", user.Email, config.EmailDebounceResetPassword); err == nil {
|
||||
if err := mail.Send(mail.Message{
|
||||
To: user.Email,
|
||||
Subject: "Reset your forgotten password",
|
||||
Template: "email/reset_password.html",
|
||||
Data: map[string]interface{}{
|
||||
"Username": user.Username,
|
||||
"URL": config.Current.BaseURL + "/forgot-password?token=" + token.Token,
|
||||
},
|
||||
}); err != nil {
|
||||
session.FlashError(w, r, "Error sending an email: %s", err)
|
||||
}
|
||||
} else {
|
||||
log.Error("LockSending: reset_password e-mail is not sent to %s: one was sent recently", user.Email)
|
||||
}
|
||||
} else {
|
||||
log.Error("Do not send 'forgot password' e-mail to %s: user is banned", user.Email)
|
||||
}
|
||||
|
||||
// Success message and redirect away.
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/controller/chat"
|
||||
"code.nonshy.com/nonshy/website/pkg/geoip"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"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/worker"
|
||||
)
|
||||
|
@ -21,6 +24,7 @@ func Search() http.HandlerFunc {
|
|||
var sortWhitelist = []string{
|
||||
"last_login_at desc",
|
||||
"created_at desc",
|
||||
"certified_at desc",
|
||||
"username",
|
||||
"username desc",
|
||||
"lower(name)",
|
||||
|
@ -32,12 +36,16 @@ func Search() http.HandlerFunc {
|
|||
// Search filters.
|
||||
var (
|
||||
isCertified = r.FormValue("certified")
|
||||
username = r.FormValue("username") // username search
|
||||
username = r.FormValue("name") // username search
|
||||
searchTerm = r.FormValue("search") // profile text search
|
||||
citySearch = r.FormValue("wcs")
|
||||
gender = r.FormValue("gender")
|
||||
orientation = r.FormValue("orientation")
|
||||
maritalStatus = r.FormValue("marital_status")
|
||||
hereFor = r.FormValue("here_for")
|
||||
friendSearch = r.FormValue("friends") == "true"
|
||||
likedSearch = r.FormValue("liked") == "true"
|
||||
onChatSearch = r.FormValue("on_chat") == "true"
|
||||
sort = r.FormValue("sort")
|
||||
sortOK bool
|
||||
)
|
||||
|
@ -48,6 +56,9 @@ func Search() http.HandlerFunc {
|
|||
ageMin, ageMax = ageMax, ageMin
|
||||
}
|
||||
|
||||
rawSearch := models.ParseSearchString(searchTerm)
|
||||
search, restricted := spam.RestrictSearchTerms(rawSearch)
|
||||
|
||||
// Get current user.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
|
@ -56,6 +67,32 @@ func Search() http.HandlerFunc {
|
|||
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
|
||||
// their coordinates now.
|
||||
myLocation, err := models.RefreshGeoIP(currentUser.ID, r)
|
||||
|
@ -63,6 +100,24 @@ func Search() http.HandlerFunc {
|
|||
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.
|
||||
for _, v := range sortWhitelist {
|
||||
if sort == v {
|
||||
|
@ -74,11 +129,39 @@ func Search() http.HandlerFunc {
|
|||
sort = "last_login_at desc"
|
||||
}
|
||||
|
||||
// Real name for certified_at
|
||||
if sort == "certified_at desc" {
|
||||
sort = "certification_photos.updated_at desc"
|
||||
}
|
||||
|
||||
// Default
|
||||
if isCertified == "" {
|
||||
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{
|
||||
PerPage: config.PageSizeMemberSearch,
|
||||
Sort: sort,
|
||||
|
@ -87,21 +170,26 @@ func Search() http.HandlerFunc {
|
|||
|
||||
users, err := models.SearchUsers(currentUser, &models.UserSearch{
|
||||
Username: username,
|
||||
InUsername: inUsername,
|
||||
Gender: gender,
|
||||
Orientation: orientation,
|
||||
MaritalStatus: maritalStatus,
|
||||
HereFor: hereFor,
|
||||
Certified: isCertified == "true",
|
||||
ProfileText: search,
|
||||
Certified: certifiedOnly,
|
||||
NearCity: city,
|
||||
NotCertified: isCertified == "false",
|
||||
InnerCircle: isCertified == "circle",
|
||||
ShyAccounts: isCertified == "shy",
|
||||
IsBanned: isCertified == "banned",
|
||||
IsDisabled: isCertified == "disabled",
|
||||
IsAdmin: isCertified == "admin",
|
||||
Friends: friendSearch,
|
||||
Liked: likedSearch,
|
||||
AgeMin: ageMin,
|
||||
AgeMax: ageMax,
|
||||
}, pager)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't search users: %s", err)
|
||||
session.FlashError(w, r, "An error has occurred: %s.", err)
|
||||
}
|
||||
|
||||
// Who's Nearby feature, get some data.
|
||||
|
@ -109,8 +197,16 @@ func Search() http.HandlerFunc {
|
|||
|
||||
// Collect usernames to map to chat online status.
|
||||
var usernames = []string{}
|
||||
var userIDs = []uint64{}
|
||||
for _, user := range users {
|
||||
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{}{
|
||||
|
@ -125,19 +221,27 @@ func Search() http.HandlerFunc {
|
|||
"MaritalStatus": maritalStatus,
|
||||
"HereFor": hereFor,
|
||||
"EmailOrUsername": username,
|
||||
"Search": searchTerm,
|
||||
"City": citySearch,
|
||||
"AgeMin": ageMin,
|
||||
"AgeMax": ageMax,
|
||||
"FriendSearch": friendSearch,
|
||||
"LikedSearch": likedSearch,
|
||||
"OnChatSearch": onChatSearch,
|
||||
"Sort": sort,
|
||||
|
||||
// Restricted Search errors.
|
||||
"RestrictedSearchError": restricted,
|
||||
|
||||
// Photo counts mapped to users
|
||||
"PhotoCountMap": models.MapPhotoCounts(users),
|
||||
|
||||
// Map Shy Account badges for these results
|
||||
"ShyMap": models.MapShyAccounts(users),
|
||||
|
||||
// Map friendships to these users.
|
||||
// Map friendships and likes to these users.
|
||||
"FriendMap": models.MapFriends(currentUser, users),
|
||||
"LikedMap": models.MapLikes(currentUser, "users", likedIDs),
|
||||
|
||||
// Users on the chat room map.
|
||||
"UserOnChatMap": worker.GetChatStatistics().MapUsersOnline(usernames),
|
||||
|
@ -145,7 +249,7 @@ func Search() http.HandlerFunc {
|
|||
// Current user's location setting.
|
||||
"MyLocation": myLocation,
|
||||
"GeoIPInsights": insights,
|
||||
"DistanceMap": models.MapDistances(currentUser, users),
|
||||
"DistanceMap": models.MapDistances(currentUser, city, users),
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/geoip"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
|
@ -16,8 +17,10 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/redis"
|
||||
"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/utility"
|
||||
"code.nonshy.com/nonshy/website/pkg/worker"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
|
@ -50,11 +53,19 @@ func Settings() http.HandlerFunc {
|
|||
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
|
||||
var hashtag string
|
||||
|
||||
// Are we POSTing?
|
||||
if r.Method == http.MethodPost {
|
||||
|
||||
// Will they BECOME a Shy Account with this change?
|
||||
var wasShy = user.IsShy()
|
||||
|
||||
intent := r.PostFormValue("intent")
|
||||
switch intent {
|
||||
case "profile":
|
||||
|
@ -108,7 +119,15 @@ func Settings() http.HandlerFunc {
|
|||
|
||||
// Set profile attributes.
|
||||
for _, attr := range config.ProfileFields {
|
||||
user.SetProfileField(attr, r.PostFormValue(attr))
|
||||
var value = strings.TrimSpace(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.
|
||||
|
@ -167,6 +186,7 @@ func Settings() http.HandlerFunc {
|
|||
for _, field := range []string{
|
||||
"hero-text-dark",
|
||||
"card-lightness",
|
||||
"website-theme",
|
||||
} {
|
||||
value := r.PostFormValue(field)
|
||||
user.SetProfileField(field, value)
|
||||
|
@ -204,6 +224,7 @@ func Settings() http.HandlerFunc {
|
|||
var (
|
||||
visibility = models.UserVisibility(r.PostFormValue("visibility"))
|
||||
dmPrivacy = r.PostFormValue("dm_privacy")
|
||||
ppPrivacy = r.PostFormValue("private_photo_gate")
|
||||
)
|
||||
|
||||
user.Visibility = models.UserVisibilityPublic
|
||||
|
@ -216,6 +237,7 @@ func Settings() http.HandlerFunc {
|
|||
|
||||
// Set profile field prefs.
|
||||
user.SetProfileField("dm_privacy", dmPrivacy)
|
||||
user.SetProfileField("private_photo_gate", ppPrivacy)
|
||||
|
||||
if err := user.Save(); err != nil {
|
||||
session.FlashError(w, r, "Failed to save user to database: %s", err)
|
||||
|
@ -260,6 +282,28 @@ func Settings() http.HandlerFunc {
|
|||
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":
|
||||
hashtag = "#location"
|
||||
var (
|
||||
|
@ -293,32 +337,91 @@ func Settings() http.HandlerFunc {
|
|||
case "settings":
|
||||
hashtag = "#account"
|
||||
var (
|
||||
oldPassword = r.PostFormValue("old_password")
|
||||
changeEmail = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_email")))
|
||||
password1 = strings.TrimSpace(r.PostFormValue("new_password"))
|
||||
password2 = strings.TrimSpace(r.PostFormValue("new_password2"))
|
||||
oldPassword = r.PostFormValue("old_password")
|
||||
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"))
|
||||
password2 = strings.TrimSpace(r.PostFormValue("new_password2"))
|
||||
)
|
||||
|
||||
// Their old password is needed to make any changes to their account.
|
||||
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.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
templates.Redirect(w, r.URL.Path+hashtag)
|
||||
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?
|
||||
if changeEmail != user.Email {
|
||||
// Validate the email.
|
||||
if _, err := nm.ParseAddress(changeEmail); err != nil {
|
||||
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
templates.Redirect(w, r.URL.Path+hashtag)
|
||||
return
|
||||
}
|
||||
|
||||
// Email must not already exist.
|
||||
if _, err := models.FindUser(changeEmail); err == nil {
|
||||
session.FlashError(w, r, "That email address is already in use.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
templates.Redirect(w, r.URL.Path+hashtag)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -330,7 +433,7 @@ func Settings() http.HandlerFunc {
|
|||
}
|
||||
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)
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
templates.Redirect(w, r.URL.Path+hashtag)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -374,6 +477,13 @@ func Settings() http.HandlerFunc {
|
|||
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+".")
|
||||
return
|
||||
}
|
||||
|
@ -392,6 +502,9 @@ func Settings() http.HandlerFunc {
|
|||
// Count of subscribed comment threads.
|
||||
vars["SubscriptionCount"] = models.CountSubscriptions(user)
|
||||
|
||||
// Count of push notification subscriptions.
|
||||
vars["PushNotificationsCount"] = models.CountPushNotificationSubscriptions(user)
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
|
|
@ -14,6 +14,7 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/redis"
|
||||
"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/utility"
|
||||
"github.com/google/uuid"
|
||||
|
@ -60,7 +61,6 @@ func Signup() http.HandlerFunc {
|
|||
}
|
||||
|
||||
var token SignupToken
|
||||
log.Info("SignupToken: %s", tokenStr)
|
||||
if tokenStr != "" {
|
||||
// Validate it.
|
||||
if err := redis.Get(fmt.Sprintf(config.SignupTokenRedisKey, tokenStr), &token); err != nil || token.Token != tokenStr {
|
||||
|
@ -72,7 +72,6 @@ func Signup() http.HandlerFunc {
|
|||
vars["SignupToken"] = tokenStr
|
||||
vars["Email"] = token.Email
|
||||
}
|
||||
log.Info("Vars: %+v", vars)
|
||||
|
||||
// Posting?
|
||||
if r.Method == http.MethodPost {
|
||||
|
@ -86,8 +85,34 @@ func Signup() http.HandlerFunc {
|
|||
password = strings.TrimSpace(r.PostFormValue("password"))
|
||||
password2 = strings.TrimSpace(r.PostFormValue("password2"))
|
||||
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.
|
||||
if vars["SignupToken"] != "" && email != vars["Email"] {
|
||||
session.FlashError(w, r, "This email address is not verified. Please start over from the beginning.")
|
||||
|
@ -95,27 +120,10 @@ func Signup() http.HandlerFunc {
|
|||
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.
|
||||
vars["Email"] = email
|
||||
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.
|
||||
if _, err := nm.ParseAddress(email); err != nil {
|
||||
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
|
||||
|
@ -131,20 +139,29 @@ func Signup() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Already an account?
|
||||
if _, err := models.FindUser(email); err == nil {
|
||||
if user, err := models.FindUser(email); err == nil {
|
||||
// We don't want to admit that the email already is registered, so send an email to the
|
||||
// address in case the user legitimately forgot, but flash the regular success message.
|
||||
err := mail.Send(mail.Message{
|
||||
To: email,
|
||||
Subject: "You already have a nonshy account",
|
||||
Template: "email/already_signed_up.html",
|
||||
Data: map[string]interface{}{
|
||||
"Title": config.Title,
|
||||
"URL": config.Current.BaseURL + "/forgot-password",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Error sending an email: %s", err)
|
||||
if user.IsBanned() {
|
||||
log.Error("Do not send signup e-mail to %s: user is banned", email)
|
||||
} else {
|
||||
if err := mail.LockSending("signup", email, config.EmailDebounceDefault); err == nil {
|
||||
err := mail.Send(mail.Message{
|
||||
To: email,
|
||||
Subject: "You already have a nonshy account",
|
||||
Template: "email/already_signed_up.html",
|
||||
Data: map[string]interface{}{
|
||||
"Title": config.Title,
|
||||
"URL": config.Current.BaseURL + "/forgot-password",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Error sending an email: %s", err)
|
||||
}
|
||||
|
||||
} else {
|
||||
log.Error("LockSending: signup e-mail is not sent to %s: one was sent recently", email)
|
||||
}
|
||||
}
|
||||
|
||||
session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email)
|
||||
|
@ -163,20 +180,38 @@ func Signup() http.HandlerFunc {
|
|||
session.FlashError(w, r, "Error creating a link to send you: %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)
|
||||
// Is the app not configured to send email?
|
||||
if !config.Current.Mail.Enabled && !config.SkipEmailVerification {
|
||||
// Log the signup token for local dev.
|
||||
log.Error("Signup: the app is not configured to send email. To continue, visit the URL: /signup?token=%s", token.Token)
|
||||
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
|
||||
}
|
||||
|
||||
if err := mail.LockSending("signup", email, config.SignupTokenExpires); err == nil {
|
||||
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)
|
||||
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
|
@ -205,7 +240,6 @@ func Signup() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Full sign-up step (w/ email verification token), validate more things.
|
||||
var hasError bool
|
||||
if len(password) < 3 {
|
||||
session.FlashError(w, r, "Please enter a password longer than 3 characters.")
|
||||
hasError = true
|
||||
|
@ -214,8 +248,9 @@ func Signup() http.HandlerFunc {
|
|||
hasError = true
|
||||
}
|
||||
|
||||
if !config.UsernameRegexp.MatchString(username) {
|
||||
session.FlashError(w, r, "Your username must consist of only numbers, letters, - . and be 3-32 characters.")
|
||||
// Validate the username is OK: well formatted, not reserved, not existing.
|
||||
if err := models.IsValidUsername(username); err != nil {
|
||||
session.FlashError(w, r, err.Error())
|
||||
hasError = true
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,6 @@ package account
|
|||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
|
@ -14,18 +13,15 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
var NotesURLRegexp = regexp.MustCompile(`^/notes/u/([^@]+?)$`)
|
||||
|
||||
// User notes page (/notes/u/username)
|
||||
func UserNotes() http.HandlerFunc {
|
||||
tmpl := templates.Must("account/user_notes.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse the username out of the URL parameters.
|
||||
var username string
|
||||
m := NotesURLRegexp.FindStringSubmatch(r.URL.Path)
|
||||
if m != nil {
|
||||
username = m[1]
|
||||
}
|
||||
var (
|
||||
username = r.PathValue("username")
|
||||
show = r.FormValue("show") // admin feedback filter
|
||||
)
|
||||
|
||||
// Find this user.
|
||||
user, err := models.FindUser(username)
|
||||
|
@ -115,7 +111,7 @@ func UserNotes() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Paginate feedback & reports.
|
||||
if fb, err := models.PaginateFeedbackAboutUser(user, fbPager); err != nil {
|
||||
if fb, err := models.PaginateFeedbackAboutUser(user, show, fbPager); err != nil {
|
||||
session.FlashError(w, r, "Paginating feedback on this user: %s", err)
|
||||
} else {
|
||||
feedback = fb
|
||||
|
@ -148,6 +144,7 @@ func UserNotes() http.HandlerFunc {
|
|||
"MyNote": myNote,
|
||||
|
||||
// Admin concerns.
|
||||
"Show": show,
|
||||
"Feedback": feedback,
|
||||
"FeedbackPager": fbPager,
|
||||
"OtherNotes": otherNotes,
|
||||
|
@ -208,7 +205,7 @@ func MyNotes() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Admin notes?
|
||||
if adminNotes && !currentUser.IsAdmin {
|
||||
if adminNotes && !currentUser.HasAdminScope(config.ScopeUserNotes) {
|
||||
adminNotes = false
|
||||
}
|
||||
|
||||
|
|
62
pkg/controller/admin/add_user.go
Normal file
62
pkg/controller/admin/add_user.go
Normal file
|
@ -0,0 +1,62 @@
|
|||
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
|
||||
}
|
||||
})
|
||||
}
|
128
pkg/controller/admin/change_log.go
Normal file
128
pkg/controller/admin/change_log.go
Normal file
|
@ -0,0 +1,128 @@
|
|||
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
|
||||
}
|
||||
})
|
||||
}
|
|
@ -3,6 +3,7 @@ package admin
|
|||
import (
|
||||
"net/http"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/encryption/coldstorage"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
|
@ -10,7 +11,10 @@ import (
|
|||
func Dashboard() http.HandlerFunc {
|
||||
tmpl := templates.Must("admin/dashboard.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := tmpl.Execute(w, r, nil); err != nil {
|
||||
var vars = map[string]interface{}{
|
||||
"ColdStorageWarning": coldstorage.Warning(),
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -14,6 +14,13 @@ import (
|
|||
// Feedback controller (/admin/feedback)
|
||||
func Feedback() http.HandlerFunc {
|
||||
tmpl := templates.Must("admin/feedback.html")
|
||||
|
||||
// Whitelist for ordering options.
|
||||
var sortWhitelist = []string{
|
||||
"created_at desc",
|
||||
"created_at asc",
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Query params.
|
||||
var (
|
||||
|
@ -23,8 +30,26 @@ func Feedback() http.HandlerFunc {
|
|||
profile = r.FormValue("profile") == "true" // visit associated user profile
|
||||
verdict = r.FormValue("verdict")
|
||||
fb *models.Feedback
|
||||
|
||||
// Search filters.
|
||||
searchQuery = r.FormValue("q")
|
||||
search = models.ParseSearchString(searchQuery)
|
||||
subject = r.FormValue("subject")
|
||||
sort = r.FormValue("sort")
|
||||
sortOK bool
|
||||
)
|
||||
|
||||
// Sort options.
|
||||
for _, v := range sortWhitelist {
|
||||
if sort == v {
|
||||
sortOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !sortOK {
|
||||
sort = sortWhitelist[0]
|
||||
}
|
||||
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't get your current user: %s", err)
|
||||
|
@ -44,32 +69,50 @@ func Feedback() http.HandlerFunc {
|
|||
|
||||
// Are we visiting a linked resource (via TableID)?
|
||||
if fb != nil && fb.TableID > 0 && visit {
|
||||
// New (Oct 17 '24): feedbacks may carry an AboutUserID, e.g. for photos in case the reported
|
||||
// photo is removed then the associated owner of the photo is still carried in the report.
|
||||
var aboutUser *models.User
|
||||
if fb.AboutUserID > 0 {
|
||||
if user, err := models.GetUser(fb.AboutUserID); err == nil {
|
||||
aboutUser = user
|
||||
}
|
||||
}
|
||||
|
||||
switch fb.TableName {
|
||||
case "users":
|
||||
user, err := models.GetUser(fb.TableID)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
|
||||
} else {
|
||||
// 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)
|
||||
}
|
||||
templates.Redirect(w, "/u/"+user.Username)
|
||||
return
|
||||
}
|
||||
case "photos":
|
||||
pic, err := models.GetPhoto(fb.TableID)
|
||||
if err != nil {
|
||||
// If there was an About User, visit their profile page instead.
|
||||
if aboutUser != nil {
|
||||
session.FlashError(w, r, "The photo #%d was deleted, visiting the owner's profile page instead.", fb.TableID)
|
||||
templates.Redirect(w, "/u/"+aboutUser.Username)
|
||||
return
|
||||
}
|
||||
|
||||
session.FlashError(w, r, "Couldn't get photo %d: %s", fb.TableID, err)
|
||||
} else {
|
||||
// Going to the user's profile page?
|
||||
if profile {
|
||||
user, err := models.GetUser(pic.UserID)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
|
||||
} else {
|
||||
templates.Redirect(w, "/u/"+user.Username)
|
||||
|
||||
// Going forward: the aboutUser will be populated, this is for legacy reports.
|
||||
if aboutUser == nil {
|
||||
if user, err := models.GetUser(pic.UserID); err == nil {
|
||||
aboutUser = user
|
||||
} else {
|
||||
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if aboutUser != nil {
|
||||
templates.Redirect(w, "/u/"+aboutUser.Username)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
@ -107,6 +150,15 @@ func Feedback() http.HandlerFunc {
|
|||
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:
|
||||
session.FlashError(w, r, "Couldn't visit TableID %s/%d: not a supported TableName", fb.TableName, fb.TableID)
|
||||
}
|
||||
|
@ -145,31 +197,51 @@ func Feedback() http.HandlerFunc {
|
|||
pager := &models.Pagination{
|
||||
Page: 1,
|
||||
PerPage: config.PageSizeAdminFeedback,
|
||||
Sort: "updated_at desc",
|
||||
Sort: sort,
|
||||
}
|
||||
pager.ParsePage(r)
|
||||
page, err := models.PaginateFeedback(acknowledged, intent, pager)
|
||||
page, err := models.PaginateFeedback(acknowledged, intent, subject, search, pager)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't load feedback from DB: %s", err)
|
||||
}
|
||||
|
||||
// Map user IDs.
|
||||
var userIDs = []uint64{}
|
||||
var (
|
||||
userIDs = []uint64{}
|
||||
photoIDs = []uint64{}
|
||||
)
|
||||
for _, p := range page {
|
||||
if p.UserID > 0 {
|
||||
userIDs = append(userIDs, p.UserID)
|
||||
}
|
||||
|
||||
if p.TableName == "photos" && p.TableID > 0 {
|
||||
photoIDs = append(photoIDs, p.TableID)
|
||||
}
|
||||
}
|
||||
userMap, err := models.MapUsers(currentUser, userIDs)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't map user IDs: %s", err)
|
||||
}
|
||||
|
||||
// Map photo IDs.
|
||||
photoMap, err := models.MapPhotos(photoIDs)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't map photo IDs: %s", err)
|
||||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
// Filter settings.
|
||||
"DistinctSubjects": models.DistinctFeedbackSubjects(),
|
||||
"SearchTerm": searchQuery,
|
||||
"Subject": subject,
|
||||
"Sort": sort,
|
||||
|
||||
"Intent": intent,
|
||||
"Acknowledged": acknowledged,
|
||||
"Feedback": page,
|
||||
"UserMap": userMap,
|
||||
"PhotoMap": photoMap,
|
||||
"Pager": pager,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
|
|
43
pkg/controller/admin/transparency.go
Normal file
43
pkg/controller/admin/transparency.go
Normal file
|
@ -0,0 +1,43 @@
|
|||
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
|
||||
}
|
||||
})
|
||||
}
|
|
@ -1,11 +1,14 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||
"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/deletion"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
|
@ -24,6 +27,14 @@ func MarkPhotoExplicit() http.HandlerFunc {
|
|||
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 {
|
||||
|
@ -41,11 +52,18 @@ func MarkPhotoExplicit() http.HandlerFunc {
|
|||
}
|
||||
|
||||
photo.Explicit = true
|
||||
photo.Flagged = true
|
||||
if err := photo.Save(); err != nil {
|
||||
session.FlashError(w, r, "Couldn't save photo: %s", err)
|
||||
} else {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
@ -100,11 +118,88 @@ func UserActions() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Get their block lists.
|
||||
insights, err := models.GetBlocklistInsights(user)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Error getting blocklist insights: %s", err)
|
||||
}
|
||||
vars["BlocklistInsights"] = insights
|
||||
|
||||
// Also surface counts of admin blocks.
|
||||
count, total := models.CountBlockedAdminUsers(user)
|
||||
vars["AdminBlockCount"] = count
|
||||
vars["AdminBlockTotal"] = total
|
||||
case "chat.rules":
|
||||
// Chat Moderation Rules.
|
||||
if !currentUser.HasAdminScope(config.ScopeChatModerator) {
|
||||
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeChatModerator)
|
||||
templates.Redirect(w, "/admin")
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
// Rules list for the change log.
|
||||
var newRules = "(none)"
|
||||
if rule, ok := r.PostForm["rules"]; ok && len(rule) > 0 {
|
||||
newRules = strings.Join(rule, ",")
|
||||
user.SetProfileField("chat_moderation_rules", newRules)
|
||||
if err := user.Save(); err != nil {
|
||||
session.FlashError(w, r, "Error saving the user's chat rules: %s", err)
|
||||
} else {
|
||||
session.Flash(w, r, "Chat moderation rules have been updated!")
|
||||
}
|
||||
} else {
|
||||
user.DeleteProfileField("chat_moderation_rules")
|
||||
session.Flash(w, r, "All chat moderation rules have been cleared for user: %s", user.Username)
|
||||
}
|
||||
|
||||
templates.Redirect(w, "/u/"+user.Username)
|
||||
|
||||
// Log the new rules to the changelog.
|
||||
models.LogEvent(
|
||||
user,
|
||||
currentUser,
|
||||
"updated",
|
||||
"chat.rules",
|
||||
user.ID,
|
||||
fmt.Sprintf(
|
||||
"An admin has updated the chat moderation rules for this user.\n\n"+
|
||||
"The update rules are: %s",
|
||||
newRules,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
vars["ChatModerationRules"] = config.ChatModerationRules
|
||||
case "essays":
|
||||
// Edit their profile essays easily.
|
||||
if !currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||
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":
|
||||
// Scope check.
|
||||
if !currentUser.HasAdminScope(config.ScopeUserImpersonate) {
|
||||
|
@ -141,6 +236,14 @@ func UserActions() http.HandlerFunc {
|
|||
user.Save()
|
||||
session.Flash(w, r, "User ban status updated!")
|
||||
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
|
||||
}
|
||||
case "promote":
|
||||
|
@ -156,6 +259,34 @@ func UserActions() http.HandlerFunc {
|
|||
user.IsAdmin = action == "promote"
|
||||
user.Save()
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
@ -174,6 +305,14 @@ func UserActions() http.HandlerFunc {
|
|||
session.Flash(w, r, "User has been deleted!")
|
||||
}
|
||||
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
|
||||
}
|
||||
default:
|
||||
|
|
|
@ -79,6 +79,9 @@ func Report() http.HandlerFunc {
|
|||
|
||||
log.Debug("Got chat report: %+v", report)
|
||||
|
||||
// Make a clickable profile link for the channel ID (other user).
|
||||
otherUsername := strings.TrimPrefix(report.Channel, "@")
|
||||
|
||||
// Create an admin Feedback model.
|
||||
fb := &models.Feedback{
|
||||
Intent: "report",
|
||||
|
@ -87,7 +90,7 @@ func Report() http.HandlerFunc {
|
|||
"A message was reported on the chat room!\n\n"+
|
||||
"* From username: [%s](/u/%s)\n"+
|
||||
"* About username: [%s](/u/%s)\n"+
|
||||
"* Channel: **%s**\n"+
|
||||
"* Channel: [**%s**](/u/%s)\n"+
|
||||
"* Timestamp: %s\n"+
|
||||
"* Classification: %s\n"+
|
||||
"* User comment: %s\n\n"+
|
||||
|
@ -95,7 +98,7 @@ func Report() http.HandlerFunc {
|
|||
"The reported message on chat was:\n\n%s",
|
||||
report.FromUsername, report.FromUsername,
|
||||
report.AboutUsername, report.AboutUsername,
|
||||
report.Channel,
|
||||
report.Channel, otherUsername,
|
||||
report.Timestamp,
|
||||
report.Reason,
|
||||
report.Comment,
|
||||
|
@ -116,6 +119,7 @@ func Report() http.HandlerFunc {
|
|||
if err == nil {
|
||||
fb.TableName = "users"
|
||||
fb.TableID = targetUser.ID
|
||||
fb.AboutUserID = targetUser.ID
|
||||
} else {
|
||||
log.Error("BareRTC Chat Feedback: couldn't find user ID for AboutUsername=%s: %s", report.AboutUsername, err)
|
||||
}
|
||||
|
@ -193,12 +197,20 @@ func Profile() http.HandlerFunc {
|
|||
|
||||
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{
|
||||
OK: true,
|
||||
ProfileFields: []ProfileField{
|
||||
{
|
||||
Name: "Member Since",
|
||||
Value: fmt.Sprintf("%s ago", utility.FormatDurationCoarse(time.Since(currentUser.CreatedAt))),
|
||||
Name: "Certified since",
|
||||
Value: fmt.Sprintf("%s ago", utility.FormatDurationCoarse(time.Since(memberSinceDate))),
|
||||
},
|
||||
{
|
||||
Name: "📸 Gallery",
|
||||
|
|
|
@ -49,3 +49,17 @@ func SendJSON(w http.ResponseWriter, statusCode int, v interface{}) {
|
|||
w.WriteHeader(statusCode)
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/photo"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
)
|
||||
|
||||
|
@ -96,25 +97,20 @@ func Likes() http.HandlerFunc {
|
|||
case "photos":
|
||||
if photo, err := models.GetPhoto(tableID); err == nil {
|
||||
if user, err := models.GetUser(photo.UserID); err == nil {
|
||||
// Admin safety check: in case the admin clicked 'Like' on a friends-only or private
|
||||
// picture they shouldn't have been expected to see, do not log a like.
|
||||
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) {
|
||||
// Safety check: if the current user should not see this picture, they can not "Like" it.
|
||||
// Example: you unfriended them but they still had the image on their old browser page.
|
||||
if ok, _ := photo.ShouldBeSeenBy(currentUser); !ok {
|
||||
SendJSON(w, http.StatusForbidden, Response{
|
||||
Error: "You are not allowed to like that photo.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mark this photo as 'viewed' if it received a like.
|
||||
// Example: on a gallery view the photo is only 'viewed' if interacted with (lightbox),
|
||||
// going straight for the 'Like' button should count as well.
|
||||
photo.View(currentUser)
|
||||
|
||||
targetUser = user
|
||||
}
|
||||
} else {
|
||||
|
@ -124,7 +120,6 @@ func Likes() http.HandlerFunc {
|
|||
log.Error("subject is users, find %d", tableID)
|
||||
if user, err := models.GetUser(tableID); err == nil {
|
||||
targetUser = user
|
||||
log.Warn("found user %s", targetUser.Username)
|
||||
|
||||
// Blocking safety check: if either user blocks the other, liking is not allowed.
|
||||
if models.IsBlocking(currentUser.ID, user.ID) {
|
||||
|
@ -175,7 +170,7 @@ func Likes() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Remove the target's notification about this like.
|
||||
models.RemoveSpecificNotification(targetUser.ID, models.NotificationLike, req.TableName, tableID)
|
||||
models.RemoveSpecificNotificationAboutUser(targetUser.ID, currentUser.ID, models.NotificationLike, req.TableName, tableID)
|
||||
} else {
|
||||
if err := models.AddLike(currentUser, req.TableName, tableID); err != nil {
|
||||
SendJSON(w, http.StatusBadRequest, Response{
|
||||
|
@ -202,6 +197,13 @@ func Likes() http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// Refresh cached like counts.
|
||||
if req.TableName == "photos" {
|
||||
if err := models.UpdatePhotoCachedCounts(tableID); err != nil {
|
||||
log.Error("UpdatePhotoCachedCount(%d): %s", tableID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Send success response.
|
||||
SendJSON(w, http.StatusOK, Response{
|
||||
OK: true,
|
||||
|
@ -284,7 +286,7 @@ func WhoLikes() http.HandlerFunc {
|
|||
for _, user := range users {
|
||||
result = append(result, Liker{
|
||||
Username: user.Username,
|
||||
Avatar: user.VisibleAvatarURL(currentUser),
|
||||
Avatar: photo.VisibleAvatarURL(user, currentUser),
|
||||
Relationship: user.UserRelationship,
|
||||
})
|
||||
}
|
||||
|
|
149
pkg/controller/api/mark_explicit.go
Normal file
149
pkg/controller/api/mark_explicit.go
Normal file
|
@ -0,0 +1,149 @@
|
|||
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,
|
||||
})
|
||||
})
|
||||
}
|
|
@ -1,93 +0,0 @@
|
|||
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),
|
||||
})
|
||||
})
|
||||
}
|
70
pkg/controller/api/photo.go
Normal file
70
pkg/controller/api/photo.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
)
|
||||
|
||||
// ViewPhoto API pings a view count on a photo, e.g. from the lightbox modal.
|
||||
func ViewPhoto() http.HandlerFunc {
|
||||
// Response JSON schema.
|
||||
type Response struct {
|
||||
OK bool `json:"OK"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Likes int64 `json:"likes"`
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Get the current user.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
SendJSON(w, http.StatusBadRequest, Response{
|
||||
Error: "Couldn't get current user!",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Photo ID from path parameter.
|
||||
var photoID uint64
|
||||
if id, err := strconv.Atoi(r.PathValue("photo_id")); err == nil && id > 0 {
|
||||
photoID = uint64(id)
|
||||
} else {
|
||||
SendJSON(w, http.StatusBadRequest, Response{
|
||||
Error: "Invalid photo ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Find this photo.
|
||||
photo, err := models.GetPhoto(photoID)
|
||||
if err != nil {
|
||||
SendJSON(w, http.StatusNotFound, Response{
|
||||
Error: "Photo Not Found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check permission to have seen this photo.
|
||||
if ok, err := photo.ShouldBeSeenBy(currentUser); !ok {
|
||||
log.Error("Photo %d can't be seen by %s: %s", photo.ID, currentUser.Username, err)
|
||||
SendJSON(w, http.StatusNotFound, Response{
|
||||
Error: "Photo Not Found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mark a view.
|
||||
if err := photo.View(currentUser); err != nil {
|
||||
log.Error("Update photo(%d) views: %s", photo.ID, err)
|
||||
}
|
||||
|
||||
// Send success response.
|
||||
SendJSON(w, http.StatusOK, Response{
|
||||
OK: true,
|
||||
})
|
||||
})
|
||||
}
|
106
pkg/controller/api/photosign_auth.go
Normal file
106
pkg/controller/api/photosign_auth.go
Normal file
|
@ -0,0 +1,106 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/encryption"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/photo"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
)
|
||||
|
||||
// PhotoSignAuth API protects paths like /static/photos/ to authenticated user requests only.
|
||||
func PhotoSignAuth() http.HandlerFunc {
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:",omitempty"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
logAndError := func(w http.ResponseWriter, m string, v ...interface{}) {
|
||||
log.Debug("ERROR PhotoSignAuth: "+m, v...)
|
||||
SendJSON(w, http.StatusForbidden, Response{
|
||||
Error: fmt.Sprintf(m, v...),
|
||||
})
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// We only protect the /static/photos subpath.
|
||||
// And check if the SignedPhoto feature is enabled and enforcing.
|
||||
var originalURI = r.Header.Get("X-Original-URI")
|
||||
if !config.Current.SignedPhoto.Enabled || !strings.HasPrefix(originalURI, config.PhotoWebPath) {
|
||||
SendJSON(w, http.StatusOK, Response{
|
||||
Success: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the base filename.
|
||||
var filename = strings.TrimPrefix(
|
||||
strings.SplitN(originalURI, config.PhotoWebPath, 2)[1],
|
||||
"/",
|
||||
)
|
||||
filename = strings.SplitN(filename, "?", 2)[0] // inner query string too
|
||||
|
||||
// Parse the JWT token parameter from the original URL.
|
||||
var token string
|
||||
if path, err := url.Parse(originalURI); err == nil {
|
||||
query := path.Query()
|
||||
token = query.Get("jwt")
|
||||
}
|
||||
|
||||
// The JWT token is required from here on out.
|
||||
if token == "" {
|
||||
logAndError(w, "JWT token is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we're logged in and who the current username is.
|
||||
var username string
|
||||
if currentUser, err := session.CurrentUser(r); err == nil {
|
||||
username = currentUser.Username
|
||||
}
|
||||
|
||||
// Validate the JWT token is correctly signed and not expired.
|
||||
claims, ok, err := encryption.ValidateClaims(
|
||||
token,
|
||||
[]byte(config.Current.SignedPhoto.JWTSecret),
|
||||
&photo.SignedPhotoClaims{},
|
||||
)
|
||||
if !ok || err != nil {
|
||||
logAndError(w, "When validating JWT claims: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the claims to get data to validate this request.
|
||||
c, ok := claims.(*photo.SignedPhotoClaims)
|
||||
if !ok {
|
||||
logAndError(w, "JWT claims were not the correct shape: %+v", claims)
|
||||
return
|
||||
}
|
||||
|
||||
// Was the signature for our username? (Skip if for Anyone)
|
||||
if !c.Anyone && c.Subject != username {
|
||||
logAndError(w, "That token did not belong to you")
|
||||
return
|
||||
}
|
||||
|
||||
// Is the file name correct?
|
||||
hash := photo.FilenameHash(filename)
|
||||
if hash != c.FilenameHash {
|
||||
logAndError(w, "Filename hash mismatch: fn=%s hash=%s jwt=%s", filename, hash, c.FilenameHash)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug("PhotoSignAuth: JWT Signature OK! fn=%s u=%s anyone=%v expires=%+v", filename, c.Subject, c.Anyone, c.ExpiresAt)
|
||||
|
||||
SendJSON(w, http.StatusOK, Response{
|
||||
Success: true,
|
||||
Username: username,
|
||||
})
|
||||
})
|
||||
}
|
34
pkg/controller/api/world_cities.go
Normal file
34
pkg/controller/api/world_cities.go
Normal file
|
@ -0,0 +1,34 @@
|
|||
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)
|
||||
})
|
||||
}
|
|
@ -97,6 +97,9 @@ func BlockUser() http.HandlerFunc {
|
|||
session.FlashError(w, r, "Couldn't unblock this user: %s.", err)
|
||||
} else {
|
||||
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")
|
||||
return
|
||||
|
@ -109,17 +112,35 @@ func BlockUser() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Can't block admins who have the unblockable scope.
|
||||
if user.IsAdmin && user.HasAdminScope(config.ScopeUnblockable) {
|
||||
// If the target user is an admin, log this to the admin reports page.
|
||||
if user.IsAdmin {
|
||||
// Is the target admin user unblockable?
|
||||
var (
|
||||
unblockable = user.HasAdminScope(config.ScopeUnblockable)
|
||||
footer string // qualifier for the admin report body
|
||||
)
|
||||
|
||||
// Add a footer to the report to indicate whether the block goes through.
|
||||
if unblockable {
|
||||
footer = "**Unblockable:** this admin can not be blocked, so the block was not added and the user was shown an error message."
|
||||
} else {
|
||||
footer = "**Notice:** This admin is not unblockable, so the block has been added successfully."
|
||||
}
|
||||
|
||||
// Also, include this user's current count of blocked admin users.
|
||||
count, total := models.CountBlockedAdminUsers(currentUser)
|
||||
footer += fmt.Sprintf("\n\nThis user now blocks %d of %d admin user(s) on this site.", count+1, total)
|
||||
|
||||
// For curiosity's sake, log a report.
|
||||
fb := &models.Feedback{
|
||||
Intent: "report",
|
||||
Subject: "A user tried to block an admin",
|
||||
Message: fmt.Sprintf(
|
||||
"A user has tried to block an admin user account!\n\n"+
|
||||
"* Username: %s\n* Tried to block: %s",
|
||||
"* Username: %s\n* Tried to block: %s\n\n%s",
|
||||
currentUser.Username,
|
||||
user.Username,
|
||||
footer,
|
||||
),
|
||||
UserID: currentUser.ID,
|
||||
TableName: "users",
|
||||
|
@ -129,9 +150,12 @@ func BlockUser() http.HandlerFunc {
|
|||
log.Error("Could not log feedback for user %s trying to block admin %s: %s", currentUser.Username, user.Username, err)
|
||||
}
|
||||
|
||||
session.FlashError(w, r, "You can not block site administrators.")
|
||||
templates.Redirect(w, "/u/"+username)
|
||||
return
|
||||
// If the admin is unblockable, give the user an error message and return.
|
||||
if unblockable {
|
||||
session.FlashError(w, r, "You can not block site administrators.")
|
||||
templates.Redirect(w, "/u/"+username)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Block the target user.
|
||||
|
@ -139,6 +163,9 @@ func BlockUser() http.HandlerFunc {
|
|||
session.FlashError(w, r, "Couldn't block this user: %s.", err)
|
||||
} else {
|
||||
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.
|
||||
|
|
|
@ -3,7 +3,6 @@ package chat
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
@ -11,6 +10,7 @@ import (
|
|||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/encryption"
|
||||
"code.nonshy.com/nonshy/website/pkg/geoip"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/middleware"
|
||||
|
@ -22,16 +22,17 @@ import (
|
|||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
// JWT claims.
|
||||
// Claims are the JWT claims for the BareRTC chat room.
|
||||
type Claims struct {
|
||||
// Custom claims.
|
||||
IsAdmin bool `json:"op,omitempty"`
|
||||
VIP bool `json:"vip,omitempty"`
|
||||
Avatar string `json:"img,omitempty"`
|
||||
ProfileURL string `json:"url,omitempty"`
|
||||
Nickname string `json:"nick,omitempty"`
|
||||
Emoji string `json:"emoji,omitempty"`
|
||||
Gender string `json:"gender,omitempty"`
|
||||
IsAdmin bool `json:"op,omitempty"`
|
||||
VIP bool `json:"vip,omitempty"`
|
||||
Avatar string `json:"img,omitempty"`
|
||||
ProfileURL string `json:"url,omitempty"`
|
||||
Nickname string `json:"nick,omitempty"`
|
||||
Emoji string `json:"emoji,omitempty"`
|
||||
Gender string `json:"gender,omitempty"`
|
||||
Rules []string `json:"rules,omitempty"`
|
||||
|
||||
// Standard claims. Notes:
|
||||
// subject = username
|
||||
|
@ -76,16 +77,6 @@ func Landing() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// If we are shy, block chat for now.
|
||||
if isShy {
|
||||
session.FlashError(w, r,
|
||||
"You have a Shy Account and are not allowed in the chat room at this time where our non-shy members may "+
|
||||
"be on camera.",
|
||||
)
|
||||
templates.Redirect(w, "/chat")
|
||||
return
|
||||
}
|
||||
|
||||
// Get our Chat JWT secret.
|
||||
var (
|
||||
secret = []byte(config.Current.BareRTC.JWTSecret)
|
||||
|
@ -98,14 +89,12 @@ func Landing() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Avatar URL - masked if non-public.
|
||||
avatar := photo.URLPath(currentUser.ProfilePhoto.CroppedFilename)
|
||||
avatar := photo.SignedPublicAvatarURL(currentUser.ProfilePhoto.CroppedFilename)
|
||||
switch currentUser.ProfilePhoto.Visibility {
|
||||
case models.PhotoPrivate:
|
||||
avatar = "/static/img/shy-private.png"
|
||||
case models.PhotoFriends:
|
||||
avatar = "/static/img/shy-friends.png"
|
||||
case models.PhotoInnerCircle:
|
||||
avatar = "/static/img/shy-secret.png"
|
||||
}
|
||||
|
||||
// Country flag emoji.
|
||||
|
@ -122,26 +111,29 @@ func Landing() http.HandlerFunc {
|
|||
emoji = "🍰 It's my birthday!"
|
||||
}
|
||||
|
||||
// Apply chat moderation rules.
|
||||
var rules = []string{}
|
||||
if isShy {
|
||||
// Shy account: no camera privileges.
|
||||
rules = []string{"novideo", "noimage"}
|
||||
} else if v := currentUser.GetProfileField("chat_moderation_rules"); len(v) > 0 {
|
||||
// Specific mod rules applied to the current user.
|
||||
rules = strings.Split(v, ",")
|
||||
}
|
||||
|
||||
// Create the JWT claims.
|
||||
claims := Claims{
|
||||
IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator),
|
||||
VIP: currentUser.IsInnerCircle(),
|
||||
Avatar: avatar,
|
||||
ProfileURL: "/u/" + currentUser.Username,
|
||||
Nickname: currentUser.NameOrUsername(),
|
||||
Emoji: emoji,
|
||||
Gender: Gender(currentUser),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: config.Title,
|
||||
Subject: currentUser.Username,
|
||||
ID: fmt.Sprintf("%d", currentUser.ID),
|
||||
},
|
||||
IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator),
|
||||
Avatar: avatar,
|
||||
ProfileURL: "/u/" + currentUser.Username,
|
||||
Nickname: currentUser.NameOrUsername(),
|
||||
Emoji: emoji,
|
||||
Gender: Gender(currentUser),
|
||||
VIP: isShy, // "shy accounts" use the "VIP" status for special icon in chat
|
||||
Rules: rules,
|
||||
RegisteredClaims: encryption.StandardClaims(currentUser.ID, currentUser.Username, time.Now().Add(5*time.Minute)),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
ss, err := token.SignedString(secret)
|
||||
token, err := encryption.SignClaims(claims, []byte(config.Current.BareRTC.JWTSecret))
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't sign you into the chat: %s", err)
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
|
@ -153,8 +145,19 @@ func Landing() http.HandlerFunc {
|
|||
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.
|
||||
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss)
|
||||
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+token)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -117,7 +117,18 @@ func PostComment() http.HandlerFunc {
|
|||
session.FlashError(w, r, "Error deleting your commenting: %s", err)
|
||||
} else {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
@ -151,6 +162,9 @@ func PostComment() http.HandlerFunc {
|
|||
session.FlashError(w, r, "Couldn't save comment: %s", err)
|
||||
} else {
|
||||
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)
|
||||
return
|
||||
|
@ -168,6 +182,16 @@ func PostComment() http.HandlerFunc {
|
|||
session.Flash(w, r, "Comment added!")
|
||||
templates.Redirect(w, fromURL)
|
||||
|
||||
// Refresh cached comment counts.
|
||||
if tableName == "photos" {
|
||||
if err := models.UpdatePhotoCachedCounts(tableID); err != nil {
|
||||
log.Error("UpdatePhotoCachedCount(%d): %s", tableID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Log the change.
|
||||
models.LogCreated(currentUser, "comments", comment.ID, "Posted a new comment.\n\n---\n\n"+message)
|
||||
|
||||
// Notify the recipient of the comment.
|
||||
if notifyUser != nil && notifyUser.ID != currentUser.ID && !notifyUser.NotificationOptOut(config.NotificationOptOutComments) {
|
||||
notif := &models.Notification{
|
||||
|
|
|
@ -28,12 +28,26 @@ func Subscription() http.HandlerFunc {
|
|||
templates.Redirect(w, "/")
|
||||
return
|
||||
} else {
|
||||
if idInt, err := strconv.Atoi(idStr); err != nil {
|
||||
session.FlashError(w, r, "Comment table ID invalid.")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
} else {
|
||||
tableID = uint64(idInt)
|
||||
// Is the table_id expected to be a username?
|
||||
switch tableName {
|
||||
case "friend.photos":
|
||||
// Special "Friend uploaded a new photo" opt-out.
|
||||
if user, err := models.FindUser(idStr); err != nil {
|
||||
session.FlashError(w, r, "Username not found!")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -47,7 +61,7 @@ func Subscription() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Validate everything else.
|
||||
if _, ok := models.CommentableTables[tableName]; !ok {
|
||||
if _, ok := models.SubscribableTables[tableName]; !ok {
|
||||
session.FlashError(w, r, "You can not comment on that.")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
|
@ -61,6 +75,12 @@ func Subscription() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Language to use in the flash messages.
|
||||
var kind = "comments"
|
||||
if tableName == "friend.photos" {
|
||||
kind = "new photo uploads"
|
||||
}
|
||||
|
||||
// Get their subscription.
|
||||
sub, err := models.GetSubscription(currentUser, tableName, tableID)
|
||||
if err != nil {
|
||||
|
@ -69,7 +89,15 @@ func Subscription() http.HandlerFunc {
|
|||
if _, err := models.SubscribeTo(currentUser, tableName, tableID); err != nil {
|
||||
session.FlashError(w, r, "Couldn't create subscription: %s", err)
|
||||
} else {
|
||||
session.Flash(w, r, "You will now be notified about comments on this page.")
|
||||
session.Flash(w, r, "You will now be notified about %s on this page.", kind)
|
||||
}
|
||||
} 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 {
|
||||
|
@ -79,9 +107,9 @@ func Subscription() http.HandlerFunc {
|
|||
session.FlashError(w, r, "Couldn't save your subscription settings: %s", err)
|
||||
} else {
|
||||
if subscribe {
|
||||
session.Flash(w, r, "You will now be notified about comments on this page.")
|
||||
session.Flash(w, r, "You will now be notified about %s on this page.", kind)
|
||||
} else {
|
||||
session.Flash(w, r, "You will no longer be notified about new comments on this page.")
|
||||
session.Flash(w, r, "You will no longer be notified about new %s on this page.", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package forum
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
@ -44,7 +45,7 @@ func AddEdit() http.HandlerFunc {
|
|||
return
|
||||
} else {
|
||||
// Do we have permission?
|
||||
if found.OwnerID != currentUser.ID && !currentUser.IsAdmin {
|
||||
if !found.CanEdit(currentUser) {
|
||||
templates.ForbiddenPage(w, r)
|
||||
return
|
||||
}
|
||||
|
@ -53,6 +54,13 @@ 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?
|
||||
if r.Method == http.MethodPost {
|
||||
var (
|
||||
|
@ -63,29 +71,49 @@ func AddEdit() http.HandlerFunc {
|
|||
isExplicit = r.PostFormValue("explicit") == "true"
|
||||
isPrivileged = r.PostFormValue("privileged") == "true"
|
||||
isPermitPhotos = r.PostFormValue("permit_photos") == "true"
|
||||
isInnerCircle = r.PostFormValue("inner_circle") == "true"
|
||||
isPrivate = r.PostFormValue("private") == "true"
|
||||
)
|
||||
|
||||
// Sanity check admin-only settings.
|
||||
if !currentUser.IsAdmin {
|
||||
// Sanity check admin-only settings -> default these to OFF.
|
||||
if !currentUser.HasAdminScope(config.ScopeForumAdmin) {
|
||||
isPrivileged = false
|
||||
isPermitPhotos = false
|
||||
isPrivate = false
|
||||
}
|
||||
|
||||
// Were we editing an existing forum?
|
||||
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.Description = description
|
||||
forum.Category = category
|
||||
forum.Explicit = isExplicit
|
||||
forum.Privileged = isPrivileged
|
||||
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.
|
||||
if err := forum.Save(); err == nil {
|
||||
session.Flash(w, r, "Forum has been updated!")
|
||||
templates.Redirect(w, "/forum/admin")
|
||||
|
||||
// Log the change.
|
||||
models.LogUpdated(currentUser, nil, "forums", forum.ID, "Updated the forum's settings.", diffs)
|
||||
return
|
||||
} else {
|
||||
session.FlashError(w, r, "Error saving the forum: %s", err)
|
||||
|
@ -113,12 +141,39 @@ func AddEdit() http.HandlerFunc {
|
|||
Explicit: isExplicit,
|
||||
Privileged: isPrivileged,
|
||||
PermitPhotos: isPermitPhotos,
|
||||
InnerCircle: isInnerCircle,
|
||||
Private: isPrivate,
|
||||
}
|
||||
|
||||
if err := models.CreateForum(forum); err == nil {
|
||||
session.Flash(w, r, "The forum has been created!")
|
||||
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
|
||||
} else {
|
||||
session.FlashError(w, r, "Error creating the forum: %s", err)
|
||||
|
@ -127,12 +182,20 @@ func AddEdit() http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
_ = editID
|
||||
// Get the list of moderators.
|
||||
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{}{
|
||||
"EditID": editID,
|
||||
"EditForum": forum,
|
||||
"Categories": config.ForumCategories,
|
||||
"Moderators": mods,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
118
pkg/controller/forum/browse.go
Normal file
118
pkg/controller/forum/browse.go
Normal file
|
@ -0,0 +1,118 @@
|
|||
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
|
||||
}
|
||||
})
|
||||
}
|
|
@ -16,21 +16,16 @@ func Forum() http.HandlerFunc {
|
|||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse the path parameters
|
||||
var (
|
||||
forum *models.Forum
|
||||
fragment = r.PathValue("fragment")
|
||||
forum *models.Forum
|
||||
)
|
||||
|
||||
if m := ForumPathRegexp.FindStringSubmatch(r.URL.Path); m == nil {
|
||||
log.Error("Regexp failed to parse: %s", r.URL.Path)
|
||||
// Look up the forum by its fragment.
|
||||
if found, err := models.ForumByFragment(fragment); err != nil {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
} else {
|
||||
// Look up the forum itself.
|
||||
if found, err := models.ForumByFragment(m[1]); err != nil {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
} else {
|
||||
forum = found
|
||||
}
|
||||
forum = found
|
||||
}
|
||||
|
||||
// Get the current user.
|
||||
|
@ -41,8 +36,8 @@ func Forum() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Is it an inner circle forum?
|
||||
if forum.InnerCircle && !currentUser.IsInnerCircle() {
|
||||
// Is it a private forum?
|
||||
if !forum.CanBeSeenBy(currentUser) {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
|
@ -60,7 +55,7 @@ func Forum() http.HandlerFunc {
|
|||
var pager = &models.Pagination{
|
||||
Page: 1,
|
||||
PerPage: config.PageSizeThreadList,
|
||||
Sort: "updated_at desc",
|
||||
Sort: "threads.updated_at desc",
|
||||
}
|
||||
pager.ParsePage(r)
|
||||
|
||||
|
@ -71,17 +66,28 @@ func Forum() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Inject pinned threads on top.
|
||||
threads = append(pinned, threads...)
|
||||
// Inject pinned threads on top of the first page.
|
||||
if pager.Page == 1 {
|
||||
threads = append(pinned, threads...)
|
||||
}
|
||||
|
||||
// Map the statistics (replies, views) of these 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{}{
|
||||
"Forum": forum,
|
||||
"Threads": threads,
|
||||
"ThreadMap": threadMap,
|
||||
"Pager": pager,
|
||||
"Forum": forum,
|
||||
"ForumModerators": mods,
|
||||
"ForumSubscriberCount": models.CountForumMemberships(forum),
|
||||
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
|
||||
"Threads": threads,
|
||||
"ThreadMap": threadMap,
|
||||
"Pager": pager,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
|
@ -17,17 +17,6 @@ var (
|
|||
FragmentRegexp = regexp.MustCompile(
|
||||
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.
|
||||
|
@ -44,14 +33,13 @@ func Landing() http.HandlerFunc {
|
|||
|
||||
// Get all the categorized index forums.
|
||||
// XXX: we get a large page size to get ALL official forums
|
||||
var pager = &models.Pagination{
|
||||
// This pager is hardcoded and doesn't parse from ?page= params.
|
||||
var indexPager = &models.Pagination{
|
||||
Page: 1,
|
||||
PerPage: config.PageSizeForums,
|
||||
Sort: "title asc",
|
||||
}
|
||||
pager.ParsePage(r)
|
||||
|
||||
forums, err := models.PaginateForums(currentUser, config.ForumCategories, pager)
|
||||
forums, err := models.PaginateForums(currentUser, config.ForumCategories, nil, false, indexPager)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
|
@ -61,13 +49,41 @@ func Landing() http.HandlerFunc {
|
|||
// Bucket the forums into their categories for easy front-end.
|
||||
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.
|
||||
forumMap := models.MapForumStatistics(forums)
|
||||
followMap := models.MapForumMemberships(currentUser, forums)
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Pager": pager,
|
||||
"Categories": categorized,
|
||||
"ForumMap": forumMap,
|
||||
"Pager": pager,
|
||||
"Categories": categorized,
|
||||
"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 {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
|
@ -12,7 +12,40 @@ import (
|
|||
// Manage page for forums -- admin only for now but may open up later.
|
||||
func Manage() http.HandlerFunc {
|
||||
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) {
|
||||
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.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
|
@ -21,15 +54,24 @@ func Manage() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Parse their search term.
|
||||
var search = models.ParseSearchString(searchTerm)
|
||||
|
||||
// Get forums the user owns or can manage.
|
||||
var pager = &models.Pagination{
|
||||
Page: 1,
|
||||
PerPage: config.PageSizeForumAdmin,
|
||||
Sort: "updated_at desc",
|
||||
Sort: sort,
|
||||
}
|
||||
pager.ParsePage(r)
|
||||
|
||||
forums, err := models.PaginateOwnedForums(currentUser.ID, currentUser.IsAdmin, pager)
|
||||
forums, err := models.PaginateOwnedForums(
|
||||
currentUser.ID,
|
||||
currentUser.HasAdminScope(config.ScopeForumAdmin),
|
||||
categories,
|
||||
search,
|
||||
pager,
|
||||
)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't paginate owned forums: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
|
@ -39,6 +81,15 @@ func Manage() http.HandlerFunc {
|
|||
var vars = map[string]interface{}{
|
||||
"Pager": pager,
|
||||
"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 {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
227
pkg/controller/forum/moderators.go
Normal file
227
pkg/controller/forum/moderators.go
Normal file
|
@ -0,0 +1,227 @@
|
|||
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)
|
||||
})
|
||||
}
|
|
@ -15,6 +15,7 @@ import (
|
|||
"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/spam"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
|
@ -86,6 +87,11 @@ 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?
|
||||
if len(photoID) > 0 {
|
||||
if i, err := strconv.Atoi(photoID); err == nil {
|
||||
|
@ -115,7 +121,7 @@ func NewPost() http.HandlerFunc {
|
|||
comment = found
|
||||
|
||||
// Verify that it is indeed OUR comment.
|
||||
if currentUser.ID != comment.UserID && !currentUser.HasAdminScope(config.ScopeForumModerator) {
|
||||
if currentUser.ID != comment.UserID && !canModerate {
|
||||
templates.ForbiddenPage(w, r)
|
||||
return
|
||||
}
|
||||
|
@ -161,6 +167,11 @@ func NewPost() http.HandlerFunc {
|
|||
session.FlashError(w, r, "Error deleting your post: %s", err)
|
||||
} else {
|
||||
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))
|
||||
return
|
||||
|
@ -178,6 +189,19 @@ func NewPost() http.HandlerFunc {
|
|||
|
||||
// Submitting the form.
|
||||
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.
|
||||
pollExpires, _ = strconv.Atoi(r.FormValue("poll_expires"))
|
||||
var distinctPollChoices = map[string]interface{}{}
|
||||
|
@ -315,6 +339,14 @@ func NewPost() http.HandlerFunc {
|
|||
session.FlashError(w, r, "Couldn't save comment: %s", err)
|
||||
} else {
|
||||
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))
|
||||
return
|
||||
|
@ -327,6 +359,13 @@ func NewPost() http.HandlerFunc {
|
|||
} else {
|
||||
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 commentPhoto != nil {
|
||||
commentPhoto.CommentID = reply.ID
|
||||
|
@ -358,7 +397,7 @@ func NewPost() http.HandlerFunc {
|
|||
TableName: "threads",
|
||||
TableID: thread.ID,
|
||||
Message: message,
|
||||
Link: fmt.Sprintf("/forum/thread/%d%s#p%d", thread.ID, queryString, reply.ID),
|
||||
Link: fmt.Sprintf("/go/comment?id=%d", reply.ID),
|
||||
}
|
||||
if err := models.CreateNotification(notif); err != nil {
|
||||
log.Error("Couldn't create thread reply notification for subscriber %d: %s", userID, err)
|
||||
|
@ -428,6 +467,18 @@ 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))
|
||||
return
|
||||
}
|
||||
|
|
|
@ -14,6 +14,14 @@ import (
|
|||
func Newest() http.HandlerFunc {
|
||||
tmpl := templates.Must("forum/newest.html")
|
||||
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.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
|
@ -22,6 +30,29 @@ func Newest() http.HandlerFunc {
|
|||
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.
|
||||
var pager = &models.Pagination{
|
||||
Page: 1,
|
||||
|
@ -29,7 +60,7 @@ func Newest() http.HandlerFunc {
|
|||
}
|
||||
pager.ParsePage(r)
|
||||
|
||||
posts, err := models.PaginateRecentPosts(currentUser, config.ForumCategories, pager)
|
||||
posts, err := models.PaginateRecentPosts(currentUser, categories, subscribed, allComments, pager)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
|
@ -47,9 +78,14 @@ func Newest() http.HandlerFunc {
|
|||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Pager": pager,
|
||||
"RecentPosts": posts,
|
||||
"PhotoMap": photos,
|
||||
"CurrentForumTab": "newest",
|
||||
"Pager": pager,
|
||||
"RecentPosts": posts,
|
||||
"PhotoMap": photos,
|
||||
|
||||
// Filter options.
|
||||
"WhichForums": whichForums,
|
||||
"AllComments": allComments,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
|
@ -25,6 +25,8 @@ func Search() http.HandlerFunc {
|
|||
searchTerm = r.FormValue("q")
|
||||
byUsername = r.FormValue("username")
|
||||
postType = r.FormValue("type")
|
||||
inForum = r.FormValue("in")
|
||||
categories = []string{}
|
||||
sort = r.FormValue("sort")
|
||||
sortOK bool
|
||||
)
|
||||
|
@ -45,6 +47,14 @@ func Search() http.HandlerFunc {
|
|||
postType = "all"
|
||||
}
|
||||
|
||||
// In forums
|
||||
switch inForum {
|
||||
case "official":
|
||||
categories = config.ForumCategories
|
||||
case "community":
|
||||
categories = []string{""}
|
||||
}
|
||||
|
||||
// Get the current user.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
|
@ -80,7 +90,7 @@ func Search() http.HandlerFunc {
|
|||
)
|
||||
pager.ParsePage(r)
|
||||
|
||||
posts, err := models.SearchForum(currentUser, search, filters, pager)
|
||||
posts, err := models.SearchForum(currentUser, categories, search, filters, pager)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't search the forums: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
|
@ -100,14 +110,16 @@ func Search() http.HandlerFunc {
|
|||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Pager": pager,
|
||||
"Comments": posts,
|
||||
"ThreadMap": threadMap,
|
||||
"PhotoMap": photos,
|
||||
"CurrentForumTab": "search",
|
||||
"Pager": pager,
|
||||
"Comments": posts,
|
||||
"ThreadMap": threadMap,
|
||||
"PhotoMap": photos,
|
||||
|
||||
"SearchTerm": searchTerm,
|
||||
"ByUsername": byUsername,
|
||||
"Type": postType,
|
||||
"InForum": inForum,
|
||||
"Sort": sort,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
|
|
75
pkg/controller/forum/subscribe.go
Normal file
75
pkg/controller/forum/subscribe.go
Normal file
|
@ -0,0 +1,75 @@
|
|||
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)
|
||||
})
|
||||
}
|
|
@ -2,7 +2,6 @@ package forum
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
|
@ -12,24 +11,22 @@ import (
|
|||
"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.
|
||||
func Thread() http.HandlerFunc {
|
||||
tmpl := templates.Must("forum/thread.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse the path parameters
|
||||
var (
|
||||
idStr = r.PathValue("id")
|
||||
forum *models.Forum
|
||||
thread *models.Thread
|
||||
)
|
||||
|
||||
if m := ThreadPathRegexp.FindStringSubmatch(r.URL.Path); m == nil {
|
||||
log.Error("Regexp failed to parse: %s", r.URL.Path)
|
||||
if idStr == "" {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
} else {
|
||||
if threadID, err := strconv.Atoi(m[1]); err != nil {
|
||||
if threadID, err := strconv.Atoi(idStr); err != nil {
|
||||
session.FlashError(w, r, "Invalid thread ID in the address bar.")
|
||||
templates.Redirect(w, "/forum")
|
||||
return
|
||||
|
@ -54,12 +51,16 @@ func Thread() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Is it an inner circle forum?
|
||||
if forum.InnerCircle && !currentUser.IsInnerCircle() {
|
||||
// Is it a private forum?
|
||||
if !forum.CanBeSeenBy(currentUser) {
|
||||
templates.NotFoundPage(w, r)
|
||||
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.
|
||||
if err := thread.View(currentUser.ID); err != nil {
|
||||
log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err)
|
||||
|
@ -73,7 +74,7 @@ func Thread() http.HandlerFunc {
|
|||
}
|
||||
pager.ParsePage(r)
|
||||
|
||||
comments, err := models.PaginateComments(currentUser, "threads", thread.ID, pager)
|
||||
comments, err := models.PaginateComments(currentUser, "threads", thread.ID, canModerate, pager)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't paginate comments: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
|
@ -96,14 +97,23 @@ func Thread() http.HandlerFunc {
|
|||
// Is the current user subscribed to notifications on this thread?
|
||||
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
|
||||
|
||||
// Ping this user as having used the forums today.
|
||||
go func() {
|
||||
if err := models.LogDailyForumUser(currentUser); err != nil {
|
||||
log.Error("LogDailyForumUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
|
||||
}
|
||||
}()
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Forum": forum,
|
||||
"Thread": thread,
|
||||
"Comments": comments,
|
||||
"LikeMap": commentLikeMap,
|
||||
"PhotoMap": photos,
|
||||
"Pager": pager,
|
||||
"IsSubscribed": isSubscribed,
|
||||
"Forum": forum,
|
||||
"Thread": thread,
|
||||
"Comments": comments,
|
||||
"LikeMap": commentLikeMap,
|
||||
"PhotoMap": photos,
|
||||
"Pager": pager,
|
||||
"CanModerate": canModerate,
|
||||
"IsSubscribed": isSubscribed,
|
||||
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
|
@ -10,6 +10,7 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
"code.nonshy.com/nonshy/website/pkg/webpush"
|
||||
)
|
||||
|
||||
// AddFriend controller to send a friend request.
|
||||
|
@ -60,6 +61,11 @@ func AddFriend() http.HandlerFunc {
|
|||
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
|
||||
if verdict == "reject" {
|
||||
message = fmt.Sprintf("Friend request from %s has been rejected.", username)
|
||||
|
@ -70,6 +76,12 @@ func AddFriend() http.HandlerFunc {
|
|||
session.Flash(w, r, message)
|
||||
if verdict == "reject" {
|
||||
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")
|
||||
return
|
||||
|
@ -80,6 +92,9 @@ func AddFriend() http.HandlerFunc {
|
|||
session.Flash(w, r, "You have ignored the friend request from %s.", username)
|
||||
}
|
||||
templates.Redirect(w, "/friends")
|
||||
|
||||
// Log the change.
|
||||
models.LogUpdated(currentUser, nil, "friends", user.ID, "Ignored the friend request from "+user.Username+".", nil)
|
||||
return
|
||||
} else {
|
||||
// Post the friend request.
|
||||
|
@ -101,7 +116,28 @@ func AddFriend() http.HandlerFunc {
|
|||
|
||||
session.Flash(w, r, "You accepted the friend request from %s!", username)
|
||||
templates.Redirect(w, "/friends?view=requests")
|
||||
|
||||
// Log the change.
|
||||
models.LogUpdated(currentUser, nil, "friends", user.ID, "Accepted friend request from "+user.Username+".", nil)
|
||||
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!")
|
||||
}
|
||||
|
|
80
pkg/controller/htmx/profile_activity.go
Normal file
80
pkg/controller/htmx/profile_activity.go
Normal file
|
@ -0,0 +1,80 @@
|
|||
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
|
||||
}
|
||||
})
|
||||
}
|
|
@ -4,9 +4,12 @@ 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"
|
||||
"code.nonshy.com/nonshy/website/pkg/webpush"
|
||||
)
|
||||
|
||||
// Compose a new chat coming from a user's profile page.
|
||||
|
@ -61,9 +64,25 @@ func Compose() http.HandlerFunc {
|
|||
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!")
|
||||
if from == "inbox" {
|
||||
templates.Redirect(w, fmt.Sprintf("/messages/read/%d", m.ID))
|
||||
return
|
||||
}
|
||||
templates.Redirect(w, "/messages")
|
||||
return
|
||||
|
|
|
@ -2,7 +2,6 @@ package inbox
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
|
@ -11,12 +10,16 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
var ReadURLRegexp = regexp.MustCompile(`^/messages/read/(\d+)$`)
|
||||
|
||||
// Inbox is where users receive direct messages.
|
||||
func Inbox() http.HandlerFunc {
|
||||
tmpl := templates.Must("inbox/inbox.html")
|
||||
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)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Unexpected error: could not get currentUser.")
|
||||
|
@ -35,10 +38,8 @@ func Inbox() http.HandlerFunc {
|
|||
viewThread []*models.Message
|
||||
threadPager *models.Pagination
|
||||
composeToUser *models.User
|
||||
msgId int
|
||||
)
|
||||
if uri := ReadURLRegexp.FindStringSubmatch(r.URL.Path); uri != nil {
|
||||
msgId, _ = strconv.Atoi(uri[1])
|
||||
if msgId > 0 {
|
||||
if msg, err := models.GetMessage(uint64(msgId)); err != nil {
|
||||
session.FlashError(w, r, "Message not found.")
|
||||
templates.Redirect(w, "/messages")
|
||||
|
|
|
@ -26,13 +26,15 @@ func Contact() http.HandlerFunc {
|
|||
subject = r.FormValue("subject")
|
||||
title = "Contact Us"
|
||||
message = r.FormValue("message")
|
||||
footer string // appends to the message only when posting the feedback
|
||||
replyTo = r.FormValue("email")
|
||||
trap1 = r.FormValue("url") != "https://"
|
||||
trap2 = r.FormValue("comment") != ""
|
||||
tableID int
|
||||
tableName string
|
||||
tableLabel string // front-end user feedback about selected report item
|
||||
messageRequired = true // unless we have a table ID to work with
|
||||
tableLabel string // front-end user feedback about selected report item
|
||||
aboutUser *models.User // associated user (e.g. owner of reported photo)
|
||||
messageRequired = true // unless we have a table ID to work with
|
||||
success = "Thank you for your feedback! Your message has been delivered to the website administrators."
|
||||
)
|
||||
|
||||
|
@ -55,6 +57,7 @@ func Contact() http.HandlerFunc {
|
|||
tableName = "users"
|
||||
if user, err := models.GetUser(uint64(tableID)); err == nil {
|
||||
tableLabel = fmt.Sprintf(`User account "%s"`, user.Username)
|
||||
aboutUser = user
|
||||
} else {
|
||||
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
|
||||
}
|
||||
|
@ -65,6 +68,7 @@ func Contact() http.HandlerFunc {
|
|||
if pic, err := models.GetPhoto(uint64(tableID)); err == nil {
|
||||
if user, err := models.GetUser(pic.UserID); err == nil {
|
||||
tableLabel = fmt.Sprintf(`A profile photo of user account "%s"`, user.Username)
|
||||
aboutUser = user
|
||||
} else {
|
||||
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
|
||||
}
|
||||
|
@ -74,12 +78,42 @@ func Contact() http.HandlerFunc {
|
|||
case "report.message":
|
||||
tableName = "messages"
|
||||
tableLabel = "Direct Message conversation"
|
||||
|
||||
// Find this message, and attach it to the report.
|
||||
if msg, err := models.GetMessage(uint64(tableID)); err == nil {
|
||||
var username = "[unavailable]"
|
||||
if sender, err := models.GetUser(msg.SourceUserID); err == nil {
|
||||
username = sender.Username
|
||||
aboutUser = sender
|
||||
}
|
||||
|
||||
footer = fmt.Sprintf(`
|
||||
|
||||
---
|
||||
|
||||
From: <a href="/u/%s">@%s</a>
|
||||
|
||||
%s`,
|
||||
username, username,
|
||||
markdown.Quotify(msg.Message),
|
||||
)
|
||||
}
|
||||
case "report.comment":
|
||||
tableName = "comments"
|
||||
|
||||
// Find this comment.
|
||||
if comment, err := models.GetComment(uint64(tableID)); err == nil {
|
||||
tableLabel = fmt.Sprintf(`A comment written by "%s"`, comment.User.Username)
|
||||
aboutUser = &comment.User
|
||||
} 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 {
|
||||
log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err)
|
||||
}
|
||||
|
@ -132,11 +166,15 @@ func Contact() http.HandlerFunc {
|
|||
fb := &models.Feedback{
|
||||
Intent: intent,
|
||||
Subject: subject,
|
||||
Message: message,
|
||||
Message: message + footer,
|
||||
TableName: tableName,
|
||||
TableID: uint64(tableID),
|
||||
}
|
||||
|
||||
if aboutUser != nil {
|
||||
fb.AboutUserID = aboutUser.ID
|
||||
}
|
||||
|
||||
if currentUser != nil && currentUser.ID > 0 {
|
||||
fb.UserID = currentUser.ID
|
||||
} else if replyTo != "" {
|
||||
|
|
57
pkg/controller/index/demographics.go
Normal file
57
pkg/controller/index/demographics.go
Normal file
|
@ -0,0 +1,57 @@
|
|||
package index
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/models/demographic"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
// Demographics page (/insights) to show a peek at website demographics.
|
||||
func Demographics() http.HandlerFunc {
|
||||
tmpl := templates.Must("demographics.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
refresh = r.FormValue("refresh") == "true"
|
||||
)
|
||||
|
||||
// Are we refreshing? Check if an admin is logged in.
|
||||
if refresh {
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "You must be logged in to do that!")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// Do the refresh?
|
||||
if currentUser.IsAdmin {
|
||||
_, err := demographic.Refresh()
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Refreshing the insights: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// Get website statistics to show on home page.
|
||||
demo, err := demographic.Get()
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't get website statistics: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
vars := map[string]interface{}{
|
||||
"Demographic": demo,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
|
@ -5,6 +5,7 @@ import (
|
|||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models/demographic"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
|
@ -18,7 +19,17 @@ func Create() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, nil); err != nil {
|
||||
// Get website statistics to show on home page.
|
||||
demo, err := demographic.Get()
|
||||
if err != nil {
|
||||
log.Error("demographic.Get: %s", err)
|
||||
}
|
||||
|
||||
vars := map[string]interface{}{
|
||||
"Demographic": demo,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
@ -38,3 +49,12 @@ func Manifest() http.HandlerFunc {
|
|||
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")
|
||||
})
|
||||
}
|
||||
|
|
235
pkg/controller/photo/batch_edit.go
Normal file
235
pkg/controller/photo/batch_edit.go
Normal file
|
@ -0,0 +1,235 @@
|
|||
package photo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
pphoto "code.nonshy.com/nonshy/website/pkg/photo"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
// BatchEdit controller (/photo/batch-edit?id=N) to change properties about your picture.
|
||||
func BatchEdit() http.HandlerFunc {
|
||||
tmpl := templates.Must("photo/batch_edit.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
// Form params
|
||||
intent = r.FormValue("intent")
|
||||
photoIDs []uint64
|
||||
)
|
||||
|
||||
// Collect the photo ID params.
|
||||
if value, ok := r.Form["id"]; ok {
|
||||
for _, idStr := range value {
|
||||
if photoID, err := strconv.Atoi(idStr); err == nil {
|
||||
photoIDs = append(photoIDs, uint64(photoID))
|
||||
} else {
|
||||
log.Error("parsing photo ID %s: %s", idStr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validation.
|
||||
if len(photoIDs) == 0 || len(photoIDs) > 100 {
|
||||
session.FlashError(w, r, "Invalid number of photo IDs.")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// Find these photos by ID.
|
||||
photos, err := models.GetPhotos(photoIDs)
|
||||
if err != nil {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Load the current user.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate permission to edit all of these photos.
|
||||
var (
|
||||
ownerIDs []uint64
|
||||
)
|
||||
for _, photo := range photos {
|
||||
|
||||
if !photo.CanBeEditedBy(currentUser) {
|
||||
templates.ForbiddenPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ownerIDs = append(ownerIDs, photo.UserID)
|
||||
}
|
||||
|
||||
// Load the photo owners.
|
||||
var (
|
||||
owners, _ = models.MapUsers(currentUser, ownerIDs)
|
||||
wasShy = map[uint64]bool{} // record if this change may make them shy
|
||||
redirectURI = "/" // go first owner's gallery
|
||||
|
||||
// Are any of them a user's profile photo? (map userID->true) so we know
|
||||
// who to unlink the picture from first and avoid a postgres error.
|
||||
wasUserProfilePicture = map[uint64]bool{}
|
||||
)
|
||||
for _, user := range owners {
|
||||
redirectURI = fmt.Sprintf("/u/%s/photos", user.Username)
|
||||
wasShy[user.ID] = user.IsShy()
|
||||
|
||||
// Check if this user's profile ID is being deleted.
|
||||
if user.ProfilePhotoID != nil {
|
||||
if _, ok := photos[*user.ProfilePhotoID]; ok {
|
||||
wasUserProfilePicture[user.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm batch deletion or edit.
|
||||
if r.Method == http.MethodPost {
|
||||
|
||||
confirm := r.PostFormValue("confirm") == "true"
|
||||
if !confirm {
|
||||
session.FlashError(w, r, "Confirm you want to modify this photo.")
|
||||
templates.Redirect(w, redirectURI)
|
||||
return
|
||||
}
|
||||
|
||||
// Which intent are they executing on?
|
||||
switch intent {
|
||||
case "delete":
|
||||
batchDeletePhotos(w, r, currentUser, photos, wasUserProfilePicture, owners, redirectURI)
|
||||
case "visibility":
|
||||
batchUpdateVisibility(w, r, currentUser, photos, owners)
|
||||
default:
|
||||
session.FlashError(w, r, "Unknown intent")
|
||||
}
|
||||
|
||||
// Maybe kick them from chat if this deletion makes them into a Shy Account.
|
||||
for _, user := range owners {
|
||||
user.FlushCaches()
|
||||
if !wasShy[user.ID] && user.IsShy() {
|
||||
if _, err := chat.MaybeDisconnectUser(user); err != nil {
|
||||
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the user to their gallery.
|
||||
templates.Redirect(w, redirectURI)
|
||||
return
|
||||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Intent": intent,
|
||||
"Photos": photos,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Batch DELETE executive handler.
|
||||
func batchDeletePhotos(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
currentUser *models.User,
|
||||
photos map[uint64]*models.Photo,
|
||||
wasUserProfilePicture map[uint64]bool,
|
||||
owners map[uint64]*models.User,
|
||||
redirectURI string,
|
||||
) {
|
||||
// Delete all the photos.
|
||||
for _, photo := range photos {
|
||||
|
||||
// Was this someone's profile picture ID?
|
||||
if wasUserProfilePicture[photo.UserID] {
|
||||
log.Debug("Delete Photo: was the user's profile photo, unset ProfilePhotoID")
|
||||
if owner, ok := owners[photo.UserID]; ok {
|
||||
if err := owner.RemoveProfilePhoto(); err != nil {
|
||||
session.FlashError(w, r, "Error unsetting your current profile photo: %s", err)
|
||||
templates.Redirect(w, redirectURI)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the images from disk.
|
||||
for _, filename := range []string{
|
||||
photo.Filename,
|
||||
photo.CroppedFilename,
|
||||
} {
|
||||
if len(filename) > 0 {
|
||||
if err := pphoto.Delete(filename); err != nil {
|
||||
log.Error("Delete Photo: couldn't remove file from disk: %s: %s", filename, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Take back notifications on it.
|
||||
models.RemoveNotification("photos", photo.ID)
|
||||
|
||||
if err := photo.Delete(); err != nil {
|
||||
session.FlashError(w, r, "Couldn't delete photo: %s", err)
|
||||
templates.Redirect(w, redirectURI)
|
||||
return
|
||||
}
|
||||
|
||||
// Log the change.
|
||||
if owner, ok := owners[photo.UserID]; ok {
|
||||
models.LogDeleted(owner, currentUser, "photos", photo.ID, "Deleted the photo.", photo)
|
||||
}
|
||||
}
|
||||
|
||||
session.Flash(w, r, "%d photo(s) deleted!", len(photos))
|
||||
}
|
||||
|
||||
// Batch DELETE executive handler.
|
||||
func batchUpdateVisibility(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
currentUser *models.User,
|
||||
photos map[uint64]*models.Photo,
|
||||
owners map[uint64]*models.User,
|
||||
) {
|
||||
// Visibility setting.
|
||||
visibility := r.PostFormValue("visibility")
|
||||
|
||||
// Delete all the photos.
|
||||
for _, photo := range photos {
|
||||
|
||||
// Diff for the ChangeLog.
|
||||
diffs := []models.FieldDiff{
|
||||
models.NewFieldDiff("Visibility", photo.Visibility, visibility),
|
||||
}
|
||||
|
||||
photo.Visibility = models.PhotoVisibility(visibility)
|
||||
|
||||
// If going private, take back notifications on it.
|
||||
if photo.Visibility == models.PhotoPrivate {
|
||||
models.RemoveNotification("photos", photo.ID)
|
||||
}
|
||||
|
||||
if err := photo.Save(); err != nil {
|
||||
session.FlashError(w, r, "Error saving photo #%d: %s", photo.ID, err)
|
||||
}
|
||||
|
||||
// Log the change.
|
||||
if owner, ok := owners[photo.UserID]; ok {
|
||||
// Log the change.
|
||||
models.LogUpdated(owner, currentUser, "photos", photo.ID, "Updated the photo's settings.", diffs)
|
||||
}
|
||||
}
|
||||
|
||||
session.Flash(w, r, "%d photo(s) updated!", len(photos))
|
||||
}
|
|
@ -2,12 +2,16 @@ package photo
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||
"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/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/mail"
|
||||
|
@ -70,6 +74,8 @@ func Certification() http.HandlerFunc {
|
|||
if r.Method == http.MethodPost {
|
||||
// Are they deleting their photo?
|
||||
if r.PostFormValue("delete") == "true" {
|
||||
|
||||
// Primary cert photo
|
||||
if cert.Filename != "" {
|
||||
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)
|
||||
|
@ -77,8 +83,17 @@ func Certification() http.HandlerFunc {
|
|||
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.AdminComment = ""
|
||||
cert.SecondaryVerified = false
|
||||
cert.Save()
|
||||
|
||||
// Removing your photo = not certified again.
|
||||
|
@ -87,11 +102,22 @@ func Certification() http.HandlerFunc {
|
|||
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.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// Is it their secondary form of ID being uploaded?
|
||||
isSecondary := r.PostFormValue("secondary") == "true"
|
||||
|
||||
// Get the uploaded file.
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
|
@ -115,17 +141,38 @@ func Certification() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Are they replacing their old photo?
|
||||
if cert.Filename != "" {
|
||||
if cert.Filename != "" && !isSecondary {
|
||||
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)
|
||||
}
|
||||
} 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.
|
||||
cert.Status = models.CertificationPhotoPending
|
||||
cert.Filename = filename
|
||||
if isSecondary {
|
||||
cert.SecondaryFilename = filename
|
||||
cert.SecondaryNeeded = true
|
||||
cert.SecondaryVerified = false
|
||||
} else {
|
||||
cert.Filename = filename
|
||||
}
|
||||
cert.AdminComment = ""
|
||||
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 {
|
||||
session.FlashError(w, r, "Error saving your CertificationPhoto: %s", err)
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
|
@ -138,19 +185,31 @@ func Certification() http.HandlerFunc {
|
|||
session.FlashError(w, r, "Error saving your User data: %s", err)
|
||||
}
|
||||
|
||||
// Notify the admin email to check out this photo.
|
||||
if err := mail.Send(mail.Message{
|
||||
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)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
|
@ -284,9 +343,20 @@ func AdminCertification() http.HandlerFunc {
|
|||
} else {
|
||||
cert.Status = models.CertificationPhotoRejected
|
||||
cert.AdminComment = comment
|
||||
if comment == "(ignore)" {
|
||||
if comment == "(ignore)" || comment == "(secondary)" {
|
||||
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 {
|
||||
session.FlashError(w, r, "Failed to save CertificationPhoto: %s", err)
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
|
@ -297,6 +367,14 @@ func AdminCertification() http.HandlerFunc {
|
|||
user.Certified = false
|
||||
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?
|
||||
if comment == "(ignore)" {
|
||||
session.FlashError(w, r, "The certification photo was ignored with no comment, and will not notify the sender.")
|
||||
|
@ -304,6 +382,46 @@ func AdminCertification() http.HandlerFunc {
|
|||
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.
|
||||
notif := &models.Notification{
|
||||
UserID: user.ID,
|
||||
|
@ -316,17 +434,21 @@ func AdminCertification() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Notify the user via email.
|
||||
if err := mail.Send(mail.Message{
|
||||
To: user.Email,
|
||||
Subject: "Your certification photo has been rejected",
|
||||
Template: "email/certification_rejected.html",
|
||||
Data: map[string]interface{}{
|
||||
"Username": user.Username,
|
||||
"AdminComment": comment,
|
||||
"URL": config.Current.BaseURL + "/photo/certification",
|
||||
},
|
||||
}); err != nil {
|
||||
session.FlashError(w, r, "Note: failed to email user about the rejection: %s", err)
|
||||
if err := mail.LockSending("cert_rejected", user.Email, config.EmailDebounceDefault); err == nil {
|
||||
if err := mail.Send(mail.Message{
|
||||
To: user.Email,
|
||||
Subject: "Your certification photo has been denied",
|
||||
Template: "email/certification_rejected.html",
|
||||
Data: map[string]interface{}{
|
||||
"Username": user.Username,
|
||||
"AdminComment": comment,
|
||||
"URL": config.Current.BaseURL + "/photo/certification",
|
||||
},
|
||||
}); err != nil {
|
||||
session.FlashError(w, r, "Note: failed to email user about the rejection: %s", err)
|
||||
}
|
||||
} else {
|
||||
log.Error("LockSending: cert_rejected e-mail is not sent to %s: one was sent recently", user.Email)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -334,6 +456,36 @@ func AdminCertification() http.HandlerFunc {
|
|||
case "approve":
|
||||
cert.Status = models.CertificationPhotoApproved
|
||||
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 {
|
||||
session.FlashError(w, r, "Failed to save CertificationPhoto: %s", err)
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
|
@ -355,18 +507,25 @@ func AdminCertification() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Notify the user via email.
|
||||
if err := mail.Send(mail.Message{
|
||||
To: user.Email,
|
||||
Subject: "Your certification photo has been approved!",
|
||||
Template: "email/certification_approved.html",
|
||||
Data: map[string]interface{}{
|
||||
"Username": user.Username,
|
||||
"URL": config.Current.BaseURL,
|
||||
},
|
||||
}); err != nil {
|
||||
session.FlashError(w, r, "Note: failed to email user about the approval: %s", err)
|
||||
if err := mail.LockSending("cert_approved", user.Email, config.EmailDebounceDefault); err == nil {
|
||||
if err := mail.Send(mail.Message{
|
||||
To: user.Email,
|
||||
Subject: "Your certification photo has been approved!",
|
||||
Template: "email/certification_approved.html",
|
||||
Data: map[string]interface{}{
|
||||
"Username": user.Username,
|
||||
"URL": config.Current.BaseURL,
|
||||
},
|
||||
}); err != nil {
|
||||
session.FlashError(w, r, "Note: failed to email user about the approval: %s", err)
|
||||
}
|
||||
} else {
|
||||
log.Error("LockSending: cert_approved e-mail is not sent to %s: one was sent recently", user.Email)
|
||||
}
|
||||
|
||||
// Log the change.
|
||||
models.LogEvent(user, currentUser, models.ChangeLogApproved, "certification_photos", user.ID, "Approved the certification photo.")
|
||||
|
||||
session.Flash(w, r, "Certification photo approved!")
|
||||
default:
|
||||
session.FlashError(w, r, "Unsupported verdict option: %s", verdict)
|
||||
|
|
|
@ -5,7 +5,9 @@ import (
|
|||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
|
@ -42,9 +44,13 @@ func Edit() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// In case an admin is editing this photo: remember the HTTP request current user,
|
||||
// before the currentUser may be set to the photo's owner below.
|
||||
var requestUser = currentUser
|
||||
|
||||
// Do we have permission for this photo?
|
||||
if photo.UserID != currentUser.ID {
|
||||
if !currentUser.IsAdmin {
|
||||
if !currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||
templates.ForbiddenPage(w, r)
|
||||
return
|
||||
}
|
||||
|
@ -65,29 +71,69 @@ func Edit() http.HandlerFunc {
|
|||
|
||||
// Are we saving the changes?
|
||||
if r.Method == http.MethodPost {
|
||||
// Record if this change is going to make them a Shy Account.
|
||||
var wasShy = currentUser.IsShy()
|
||||
|
||||
var (
|
||||
caption = r.FormValue("caption")
|
||||
caption = strings.TrimSpace(r.FormValue("caption"))
|
||||
altText = strings.TrimSpace(r.FormValue("alt_text"))
|
||||
isExplicit = r.FormValue("explicit") == "true"
|
||||
isGallery = r.FormValue("gallery") == "true"
|
||||
isPinned = r.FormValue("pinned") == "true"
|
||||
visibility = models.PhotoVisibility(r.FormValue("visibility"))
|
||||
|
||||
// Profile pic fields
|
||||
setProfilePic = r.FormValue("intent") == "profile-pic"
|
||||
crop = pphoto.ParseCropCoords(r.FormValue("crop"))
|
||||
|
||||
// Are we GOING private or changing to Inner Circle?
|
||||
// Are we GOING private?
|
||||
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.
|
||||
if SiteGalleryThrottled {
|
||||
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.AltText = altText
|
||||
photo.Explicit = isExplicit
|
||||
photo.Gallery = isGallery
|
||||
photo.Pinned = isPinned
|
||||
photo.Visibility = visibility
|
||||
|
||||
// Can not use a GIF as profile pic.
|
||||
|
@ -116,7 +162,33 @@ func Edit() http.HandlerFunc {
|
|||
setProfilePic = false
|
||||
}
|
||||
|
||||
log.Error("SAVING PHOTO: %+v", photo)
|
||||
// If the user is fighting a recent Explicit flag from the community.
|
||||
if isFightingExplicitFlag {
|
||||
|
||||
// Notify the admin (unless we are an admin).
|
||||
if !requestUser.IsAdmin {
|
||||
fb := &models.Feedback{
|
||||
Intent: "report",
|
||||
Subject: "Explicit photo flag dispute",
|
||||
UserID: currentUser.ID,
|
||||
TableName: "photos",
|
||||
TableID: photo.ID,
|
||||
Message: "A user's photo was recently **flagged by the community** as Explicit, and its owner " +
|
||||
"has **removed** the Explicit setting.\n\n" +
|
||||
"Please check out the photo below and verify what its Explicit setting should be:",
|
||||
}
|
||||
|
||||
if err := models.CreateFeedback(fb); err != nil {
|
||||
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow this change but clear the Flagged status.
|
||||
photo.Flagged = false
|
||||
|
||||
// Clear the notification about this.
|
||||
models.RemoveSpecificNotification(currentUser.ID, models.NotificationExplicitPhoto, "photos", photo.ID)
|
||||
}
|
||||
|
||||
if err := photo.Save(); err != nil {
|
||||
session.FlashError(w, r, "Couldn't save photo: %s", err)
|
||||
|
@ -134,14 +206,25 @@ func Edit() http.HandlerFunc {
|
|||
// Flash success.
|
||||
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 goingPrivate || goingCircle {
|
||||
if goingPrivate {
|
||||
log.Info("The picture is GOING PRIVATE (to %s), revoke any notifications about it", photo.Visibility)
|
||||
models.RemoveNotification("photos", photo.ID)
|
||||
}
|
||||
|
||||
// Return the user to their gallery.
|
||||
templates.Redirect(w, "/photo/u/"+currentUser.Username)
|
||||
templates.Redirect(w, "/u/"+currentUser.Username+"/photos")
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -149,6 +232,10 @@ func Edit() http.HandlerFunc {
|
|||
"EditPhoto": photo,
|
||||
"SiteGalleryThrottled": SiteGalleryThrottled,
|
||||
"SiteGalleryThrottleLimit": config.SiteGalleryRateLimitMax,
|
||||
|
||||
// Available admin labels enum.
|
||||
"RequestUser": requestUser,
|
||||
"AvailableAdminLabels": config.AdminLabelPhotoOptions,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
|
@ -159,109 +246,10 @@ func Edit() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Delete controller (/photo/Delete?id=N) to change properties about your picture.
|
||||
//
|
||||
// DEPRECATED: send them to the batch-edit endpoint.
|
||||
func Delete() http.HandlerFunc {
|
||||
// Reuse the upload page but with an EditPhoto variable.
|
||||
tmpl := templates.Must("photo/delete.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Query params.
|
||||
photoID, err := strconv.Atoi(r.FormValue("id"))
|
||||
if err != nil {
|
||||
log.Error("photo.Delete: failed to parse `id` param (%s) as int: %s", r.FormValue("id"), err)
|
||||
session.FlashError(w, r, "Photo 'id' parameter required.")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// Page to redirect to in case of errors.
|
||||
redirect := fmt.Sprintf("%s?id=%d", r.URL.Path, photoID)
|
||||
|
||||
// Find this photo by ID.
|
||||
photo, err := models.GetPhoto(uint64(photoID))
|
||||
if err != nil {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Load the current user.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
templates.Redirect(w, fmt.Sprintf("/photo/batch-edit?intent=delete&id=%s", r.FormValue("id")))
|
||||
})
|
||||
}
|
||||
|
|
63
pkg/controller/photo/mark_explicit.go
Normal file
63
pkg/controller/photo/mark_explicit.go
Normal file
|
@ -0,0 +1,63 @@
|
|||
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)
|
||||
})
|
||||
}
|
|
@ -42,7 +42,11 @@ func Private() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
log.Error("pager: %+v, len: %d", pager, len(users))
|
||||
// Collect user IDs for some mappings.
|
||||
var userIDs = []uint64{}
|
||||
for _, user := range users {
|
||||
userIDs = append(userIDs, user.ID)
|
||||
}
|
||||
|
||||
// Map reverse grantee statuses.
|
||||
var GranteeMap interface{}
|
||||
|
@ -60,6 +64,12 @@ func Private() http.HandlerFunc {
|
|||
"GranteeMap": GranteeMap,
|
||||
"Users": users,
|
||||
"Pager": pager,
|
||||
|
||||
// Mapped user statuses for frontend cards.
|
||||
"PhotoCountMap": models.MapPhotoCountsByVisibility(users, models.PhotoPrivate),
|
||||
"FriendMap": models.MapFriends(currentUser, users),
|
||||
"LikedMap": models.MapLikes(currentUser, "users", userIDs),
|
||||
"ShyMap": models.MapShyAccounts(users),
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
@ -131,6 +141,15 @@ func Share() http.HandlerFunc {
|
|||
intent = r.PostFormValue("intent")
|
||||
)
|
||||
|
||||
// Is the recipient blocking this photo share?
|
||||
if intent != "decline" && intent != "revoke" {
|
||||
if ok, err := models.ShouldShowPrivateUnlockPrompt(currentUser, user); !ok {
|
||||
session.FlashError(w, r, "You are unable to share your private photos with %s: %s.", user.Username, err)
|
||||
templates.Redirect(w, "/u/"+user.Username)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If submitting, do it and redirect.
|
||||
if intent == "submit" {
|
||||
models.UnlockPrivatePhotos(currentUser.ID, user.ID)
|
||||
|
@ -145,7 +164,7 @@ func Share() http.HandlerFunc {
|
|||
Type: models.NotificationPrivatePhoto,
|
||||
TableName: "__private_photos",
|
||||
TableID: currentUser.ID,
|
||||
Link: fmt.Sprintf("/photo/u/%s?visibility=private", currentUser.Username),
|
||||
Link: fmt.Sprintf("/u/%s/photos?visibility=private", currentUser.Username),
|
||||
}
|
||||
if err := models.CreateNotification(notif); err != nil {
|
||||
log.Error("Couldn't create PrivatePhoto notification: %s", err)
|
||||
|
@ -162,10 +181,25 @@ func Share() http.HandlerFunc {
|
|||
models.RemoveSpecificNotification(user.ID, models.NotificationPrivatePhoto, "__private_photos", currentUser.ID)
|
||||
|
||||
// Revoke any "has uploaded a new private photo" notifications in this user's list.
|
||||
if err := models.RevokePrivatePhotoNotifications(currentUser, &user.ID); err != nil {
|
||||
if err := models.RevokePrivatePhotoNotifications(currentUser, user); err != nil {
|
||||
log.Error("RevokePrivatePhotoNotifications(%s): %s", currentUser.Username, err)
|
||||
}
|
||||
return
|
||||
} else if intent == "decline" {
|
||||
// Decline = they shared with me and we do not want it.
|
||||
models.RevokePrivatePhotos(user.ID, currentUser.ID)
|
||||
session.Flash(w, r, "You have declined access to see %s's private photos.", user.Username)
|
||||
|
||||
// Remove any notification we created when the grant was given.
|
||||
models.RemoveSpecificNotification(currentUser.ID, models.NotificationPrivatePhoto, "__private_photos", user.ID)
|
||||
|
||||
// Revoke any "has uploaded a new private photo" notifications in this user's list.
|
||||
if err := models.RevokePrivatePhotoNotifications(user, currentUser); err != nil {
|
||||
log.Error("RevokePrivatePhotoNotifications(%s): %s", user.Username, err)
|
||||
}
|
||||
|
||||
templates.Redirect(w, "/photo/private?view=grantee")
|
||||
return
|
||||
}
|
||||
|
||||
// The other intent is "preview" so the user gets the confirmation
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"net/http"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
|
@ -17,6 +18,9 @@ func SiteGallery() http.HandlerFunc {
|
|||
var sortWhitelist = []string{
|
||||
"created_at desc",
|
||||
"created_at asc",
|
||||
"like_count desc",
|
||||
"comment_count desc",
|
||||
"views desc",
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -64,13 +68,18 @@ func SiteGallery() http.HandlerFunc {
|
|||
// They didn't post a "Whose photos" filter, restore it from their last saved default.
|
||||
who = currentUser.GetProfileField("site_gallery_default")
|
||||
}
|
||||
if who != "friends" && who != "everybody" && who != "friends+private" {
|
||||
if who != "friends" && who != "everybody" && who != "friends+private" && who != "likes" && who != "uncertified" {
|
||||
// Default Who setting should be Friends-only, unless you have no friends.
|
||||
if myFriendCount > 0 {
|
||||
who = "friends"
|
||||
} else {
|
||||
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.
|
||||
|
@ -94,6 +103,8 @@ func SiteGallery() http.HandlerFunc {
|
|||
AdminView: adminView,
|
||||
FriendsOnly: who == "friends",
|
||||
IsShy: isShy || who == "friends+private",
|
||||
MyLikes: who == "likes",
|
||||
Uncertified: who == "uncertified",
|
||||
}, pager)
|
||||
|
||||
// Bulk load the users associated with these photos.
|
||||
|
@ -114,6 +125,13 @@ func SiteGallery() http.HandlerFunc {
|
|||
likeMap := models.MapLikes(currentUser, "photos", photoIDs)
|
||||
commentMap := models.MapCommentCounts("photos", photoIDs)
|
||||
|
||||
// Ping this user as having used the forums today.
|
||||
go func() {
|
||||
if err := models.LogDailyGalleryUser(currentUser); err != nil {
|
||||
log.Error("LogDailyGalleryUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
|
||||
}
|
||||
}()
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"IsSiteGallery": true,
|
||||
"Photos": photos,
|
||||
|
|
|
@ -2,10 +2,12 @@ package photo
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
|
@ -59,10 +61,12 @@ func Upload() http.HandlerFunc {
|
|||
// Are they POSTing?
|
||||
if r.Method == http.MethodPost {
|
||||
var (
|
||||
caption = r.PostFormValue("caption")
|
||||
caption = strings.TrimSpace(r.PostFormValue("caption"))
|
||||
altText = strings.TrimSpace(r.PostFormValue("alt_text"))
|
||||
isExplicit = r.PostFormValue("explicit") == "true"
|
||||
visibility = r.PostFormValue("visibility")
|
||||
isGallery = r.PostFormValue("gallery") == "true"
|
||||
isPinned = r.PostFormValue("pinned") == "true"
|
||||
cropCoords = r.PostFormValue("crop")
|
||||
confirm1 = r.PostFormValue("confirm1") == "true"
|
||||
confirm2 = r.PostFormValue("confirm2") == "true"
|
||||
|
@ -73,10 +77,14 @@ func Upload() http.HandlerFunc {
|
|||
isGallery = false
|
||||
}
|
||||
|
||||
if len(altText) > config.AltTextMaxLength {
|
||||
altText = altText[:config.AltTextMaxLength]
|
||||
}
|
||||
|
||||
// Are they at quota already?
|
||||
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.")
|
||||
templates.Redirect(w, "/photo/u/"+user.Username)
|
||||
templates.Redirect(w, "/u/"+user.Username+"/photos")
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -134,8 +142,10 @@ func Upload() http.HandlerFunc {
|
|||
Filename: filename,
|
||||
CroppedFilename: cropFilename,
|
||||
Caption: caption,
|
||||
AltText: altText,
|
||||
Visibility: models.PhotoVisibility(visibility),
|
||||
Gallery: isGallery,
|
||||
Pinned: isPinned,
|
||||
Explicit: isExplicit,
|
||||
}
|
||||
|
||||
|
@ -159,11 +169,24 @@ func Upload() http.HandlerFunc {
|
|||
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.
|
||||
go notifyFriendsNewPhoto(p, user)
|
||||
|
||||
session.Flash(w, r, "Your photo has been uploaded successfully.")
|
||||
templates.Redirect(w, "/photo/u/"+user.Username)
|
||||
templates.Redirect(w, "/u/"+user.Username+"/photos")
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -199,15 +222,6 @@ func notifyFriendsNewPhoto(photo *models.Photo, currentUser *models.User) {
|
|||
notifyUserIDs = models.PrivateGranteeUserIDs(currentUser.ID)
|
||||
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 {
|
||||
// Friends only: we will notify exactly the friends we selected above.
|
||||
notifyUserIDs = friendIDs
|
||||
|
|
|
@ -2,7 +2,6 @@ package photo
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
|
@ -11,21 +10,24 @@ import (
|
|||
"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.
|
||||
func UserPhotos() http.HandlerFunc {
|
||||
tmpl := templates.Must("photo/gallery.html")
|
||||
|
||||
// Whitelist for ordering options.
|
||||
var sortWhitelist = []string{
|
||||
"pinned desc nulls last, updated_at desc",
|
||||
"created_at desc",
|
||||
"created_at asc",
|
||||
"like_count desc",
|
||||
"comment_count desc",
|
||||
"views desc",
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Query params.
|
||||
var (
|
||||
username = r.PathValue("username")
|
||||
viewStyle = r.FormValue("view") // cards (default), full
|
||||
|
||||
// Search filters.
|
||||
|
@ -33,9 +35,6 @@ func UserPhotos() http.HandlerFunc {
|
|||
filterVisibility = r.FormValue("visibility")
|
||||
sort = r.FormValue("sort")
|
||||
sortOK bool
|
||||
|
||||
// Inner circle invite view?
|
||||
innerCircleInvite = r.FormValue("intent") == "inner_circle"
|
||||
)
|
||||
|
||||
// Sort options.
|
||||
|
@ -54,13 +53,6 @@ func UserPhotos() http.HandlerFunc {
|
|||
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.
|
||||
user, err := models.FindUser(username)
|
||||
if err != nil {
|
||||
|
@ -81,11 +73,6 @@ func UserPhotos() http.HandlerFunc {
|
|||
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.
|
||||
if isShy && isShyFrom {
|
||||
var vars = map[string]interface{}{
|
||||
|
@ -134,11 +121,6 @@ func UserPhotos() http.HandlerFunc {
|
|||
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 filterVisibility != "" {
|
||||
var isOK bool
|
||||
|
@ -203,13 +185,28 @@ func UserPhotos() http.HandlerFunc {
|
|||
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{}{
|
||||
"IsOwnPhotos": currentUser.ID == user.ID,
|
||||
"IsShyUser": isShy,
|
||||
"IsShyFrom": isShyFrom,
|
||||
"IsMyPrivateUnlockedFor": isGranted, // have WE granted THIS USER to see our private pics?
|
||||
"AreWeGrantedPrivate": isGrantee, // have THEY granted US private photo access.
|
||||
"ShowPrivateUnlockPrompt": showPrivateUnlockPrompt,
|
||||
"AreFriends": areFriends,
|
||||
"AreNotificationsMuted": areNotificationsMuted,
|
||||
"ProfilePictureHiddenVisibility": profilePictureHidden,
|
||||
"User": user,
|
||||
"Photos": photos,
|
||||
|
@ -217,13 +214,11 @@ func UserPhotos() http.HandlerFunc {
|
|||
"NoteCount": models.CountNotesAboutUser(currentUser, user),
|
||||
"FriendCount": models.CountFriends(user.ID),
|
||||
"PublicPhotoCount": models.CountPublicPhotos(user.ID),
|
||||
"InnerCircleMinimumPublicPhotos": config.InnerCircleMinimumPublicPhotos,
|
||||
"Pager": pager,
|
||||
"LikeMap": likeMap,
|
||||
"CommentMap": commentMap,
|
||||
"ViewStyle": viewStyle,
|
||||
"ExplicitCount": explicitCount,
|
||||
"InnerCircleInviteView": innerCircleInvite,
|
||||
|
||||
// Search filters
|
||||
"Sort": sort,
|
||||
|
|
|
@ -35,6 +35,12 @@ func View() http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// Load the current user in case they are viewing their own page.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
||||
}
|
||||
|
||||
// Find the photo's owner.
|
||||
user, err := models.GetUser(photo.UserID)
|
||||
if err != nil {
|
||||
|
@ -42,40 +48,10 @@ func View() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Load the current user in case they are viewing their own page.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
||||
}
|
||||
var isOwnPhoto = currentUser.ID == user.ID
|
||||
|
||||
// Is either one blocking?
|
||||
if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Is this 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)
|
||||
if ok, err := photo.CanBeSeenBy(currentUser); !ok {
|
||||
log.Error("Photo %d can't be seen by %s: %s", photo.ID, currentUser.Username, err)
|
||||
session.FlashError(w, r, "Photo Not Found")
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -108,6 +84,11 @@ func View() http.HandlerFunc {
|
|||
// Is the current user subscribed to notifications on this thread?
|
||||
_, isSubscribed := models.IsSubscribed(currentUser, "photos", photo.ID)
|
||||
|
||||
// Mark this photo as "Viewed" by the user.
|
||||
if err := photo.View(currentUser); err != nil {
|
||||
log.Error("Update photo(%d) views: %s", photo.ID, err)
|
||||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"IsOwnPhoto": currentUser.ID == user.ID,
|
||||
"User": user,
|
||||
|
|
150
pkg/encryption/coldstorage/coldstorage.go
Normal file
150
pkg/encryption/coldstorage/coldstorage.go
Normal file
|
@ -0,0 +1,150 @@
|
|||
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)
|
||||
}
|
|
@ -7,15 +7,12 @@
|
|||
package encryption
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"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.
|
||||
|
@ -24,32 +21,7 @@ func Encrypt(input []byte) ([]byte, error) {
|
|||
return nil, errors.New("AES key not configured")
|
||||
}
|
||||
|
||||
// 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
|
||||
return keygen.EncryptWithAESKey(input, config.Current.Encryption.AESKey)
|
||||
}
|
||||
|
||||
// EncryptString encrypts a string value and returns the cipher text.
|
||||
|
@ -63,27 +35,7 @@ func Decrypt(data []byte) ([]byte, error) {
|
|||
return nil, errors.New("AES key not configured")
|
||||
}
|
||||
|
||||
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
|
||||
return keygen.DecryptWithAESKey(data, config.Current.Encryption.AESKey)
|
||||
}
|
||||
|
||||
// DecryptString decrypts a string value from ciphertext.
|
||||
|
|
71
pkg/encryption/jwt.go
Normal file
71
pkg/encryption/jwt.go
Normal file
|
@ -0,0 +1,71 @@
|
|||
package encryption
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
// StandardClaims returns a standard JWT claim for a username.
|
||||
//
|
||||
// It will include values for Subject (username), Issuer (site title), ExpiresAt, IssuedAt, NotBefore.
|
||||
//
|
||||
// If the userID is >0, the ID field is included.
|
||||
func StandardClaims(userID uint64, username string, expiresAt time.Time) jwt.RegisteredClaims {
|
||||
claim := jwt.RegisteredClaims{
|
||||
Subject: username,
|
||||
Issuer: config.Title,
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
}
|
||||
if userID > 0 {
|
||||
claim.ID = fmt.Sprintf("%d", userID)
|
||||
}
|
||||
return claim
|
||||
}
|
||||
|
||||
// SignClaims creates and returns a signed JWT token.
|
||||
func SignClaims(claims jwt.Claims, secret []byte) (string, error) {
|
||||
// Get our Chat JWT secret.
|
||||
if len(secret) == 0 {
|
||||
return "", errors.New("JWT secret key is not configured")
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
ss, err := token.SignedString(secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return ss, nil
|
||||
}
|
||||
|
||||
// ValidateClaims checks a JWT token is signed by the site key and returns the claims.
|
||||
func ValidateClaims(tokenStr string, secret []byte, v jwt.Claims) (jwt.Claims, bool, error) {
|
||||
// Handle a JWT authentication token.
|
||||
var (
|
||||
claims jwt.Claims
|
||||
authOK bool
|
||||
)
|
||||
if tokenStr != "" {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, v, func(token *jwt.Token) (interface{}, error) {
|
||||
return secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, false, errors.New("token was not valid")
|
||||
}
|
||||
|
||||
claims = token.Claims
|
||||
authOK = true
|
||||
}
|
||||
|
||||
return claims, authOK, nil
|
||||
}
|
72
pkg/encryption/keygen/aes.go
Normal file
72
pkg/encryption/keygen/aes.go
Normal file
|
@ -0,0 +1,72 @@
|
|||
// 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
|
||||
}
|
72
pkg/encryption/keygen/aes_test.go
Normal file
72
pkg/encryption/keygen/aes_test.go
Normal file
|
@ -0,0 +1,72 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
// 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
|
||||
}
|
99
pkg/encryption/keygen/rsa.go
Normal file
99
pkg/encryption/keygen/rsa.go
Normal file
|
@ -0,0 +1,99 @@
|
|||
// 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)
|
||||
}
|
|
@ -7,9 +7,12 @@ import (
|
|||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/encryption"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/redis"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"gopkg.in/gomail.v2"
|
||||
)
|
||||
|
@ -23,6 +26,22 @@ type Message struct {
|
|||
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.
|
||||
func Send(msg Message) error {
|
||||
conf := config.Current.Mail
|
||||
|
|
|
@ -22,7 +22,6 @@ func AgeGate(user *models.User, w http.ResponseWriter, r *http.Request) (handled
|
|||
"/photo/certification",
|
||||
"/photo/private",
|
||||
"/photo/view",
|
||||
"/photo/u/",
|
||||
"/comments",
|
||||
"/users/blocked",
|
||||
"/users/block",
|
||||
|
|
|
@ -46,13 +46,19 @@ func LoginRequired(handler http.Handler) http.Handler {
|
|||
}
|
||||
|
||||
// Ping LastLoginAt for long lived sessions, but not if impersonated.
|
||||
var pingLastLoginAt bool
|
||||
if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown && !session.Impersonated(r) {
|
||||
user.LastLoginAt = time.Now()
|
||||
if err := user.Save(); err != nil {
|
||||
pingLastLoginAt = true
|
||||
if err := user.PingLastLoginAt(); err != nil {
|
||||
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?
|
||||
if AgeGate(user, w, r) {
|
||||
return
|
||||
|
@ -115,6 +121,11 @@ func CertRequired(handler http.Handler) http.Handler {
|
|||
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?
|
||||
if currentUser.Status == models.UserStatusBanned {
|
||||
session.LogoutUser(w, r)
|
||||
|
|
|
@ -3,6 +3,7 @@ package middleware
|
|||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
|
@ -18,6 +19,9 @@ func CSRF(handler http.Handler) http.Handler {
|
|||
token := MakeCSRFCookie(r, w)
|
||||
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 r.Header.Get("Content-Type") == "application/json" {
|
||||
handler.ServeHTTP(w, r.WithContext(ctx))
|
||||
|
|
25
pkg/models/backfill/backfill_photo_counts.go
Normal file
25
pkg/models/backfill/backfill_photo_counts.go
Normal file
|
@ -0,0 +1,25 @@
|
|||
package backfill
|
||||
|
||||
import (
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
)
|
||||
|
||||
// BackfillPhotoCounts recomputes the cached Likes and Comment counts on photos.
|
||||
func BackfillPhotoCounts() error {
|
||||
res := models.DB.Exec(`
|
||||
UPDATE photos
|
||||
SET like_count = (
|
||||
SELECT count(id)
|
||||
FROM likes
|
||||
WHERE table_name='photos'
|
||||
AND table_id=photos.id
|
||||
),
|
||||
comment_count = (
|
||||
SELECT count(id)
|
||||
FROM comments
|
||||
WHERE table_name='photos'
|
||||
AND table_id=photos.id
|
||||
);
|
||||
`)
|
||||
return res.Error
|
||||
}
|
|
@ -199,6 +199,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
|||
reverse = []*Block{} // Users who block the target
|
||||
userIDs = []uint64{user.ID}
|
||||
usernames = map[uint64]string{}
|
||||
admins = map[uint64]bool{}
|
||||
)
|
||||
|
||||
// Get the complete blocklist and bucket them into forward and reverse.
|
||||
|
@ -218,6 +219,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
|||
type scanItem struct {
|
||||
ID uint64
|
||||
Username string
|
||||
IsAdmin bool
|
||||
}
|
||||
var scan = []scanItem{}
|
||||
if res := DB.Table(
|
||||
|
@ -225,6 +227,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
|||
).Select(
|
||||
"id",
|
||||
"username",
|
||||
"is_admin",
|
||||
).Where(
|
||||
"id IN ?", userIDs,
|
||||
).Scan(&scan); res.Error != nil {
|
||||
|
@ -233,6 +236,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
|||
|
||||
for _, row := range scan {
|
||||
usernames[row.ID] = row.Username
|
||||
admins[row.ID] = row.IsAdmin
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -245,6 +249,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
|||
if username, ok := usernames[row.TargetUserID]; ok {
|
||||
result.Blocks = append(result.Blocks, BlocklistInsightUser{
|
||||
Username: username,
|
||||
IsAdmin: admins[row.TargetUserID],
|
||||
Date: row.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
@ -253,6 +258,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
|||
if username, ok := usernames[row.SourceUserID]; ok {
|
||||
result.BlockedBy = append(result.BlockedBy, BlocklistInsightUser{
|
||||
Username: username,
|
||||
IsAdmin: admins[row.SourceUserID],
|
||||
Date: row.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
@ -268,6 +274,7 @@ type BlocklistInsight struct {
|
|||
|
||||
type BlocklistInsightUser struct {
|
||||
Username string
|
||||
IsAdmin bool
|
||||
Date time.Time
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
@ -8,15 +9,18 @@ import (
|
|||
|
||||
// CertificationPhoto table.
|
||||
type CertificationPhoto struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
UserID uint64 `gorm:"uniqueIndex"`
|
||||
Filename string
|
||||
Filesize int64
|
||||
Status CertificationPhotoStatus
|
||||
AdminComment string
|
||||
IPAddress string // the IP they uploaded the photo from
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
UserID uint64 `gorm:"uniqueIndex"`
|
||||
Filename string
|
||||
Filesize int64
|
||||
Status CertificationPhotoStatus
|
||||
AdminComment string
|
||||
SecondaryNeeded bool // a secondary form of ID has been requested
|
||||
SecondaryFilename string // photo ID upload
|
||||
SecondaryVerified bool // mark true when ID checked so original can be deleted
|
||||
IPAddress string // the IP they uploaded the photo from
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type CertificationPhotoStatus string
|
||||
|
@ -26,6 +30,10 @@ const (
|
|||
CertificationPhotoPending CertificationPhotoStatus = "pending"
|
||||
CertificationPhotoApproved CertificationPhotoStatus = "approved"
|
||||
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.
|
||||
|
@ -43,6 +51,28 @@ func GetCertificationPhoto(userID uint64) (*CertificationPhoto, 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.
|
||||
func CertificationPhotosNeedingApproval(status CertificationPhotoStatus, pager *Pagination) ([]*CertificationPhoto, error) {
|
||||
var p = []*CertificationPhoto{}
|
||||
|
|
254
pkg/models/change_log.go
Normal file
254
pkg/models/change_log.go
Normal file
|
@ -0,0 +1,254 @@
|
|||
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
|
||||
}
|
|
@ -13,12 +13,12 @@ import (
|
|||
// Comment table - in forum threads, on profiles or photos, etc.
|
||||
type Comment struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
TableName string `gorm:"index"`
|
||||
TableID uint64 `gorm:"index"`
|
||||
TableName string `gorm:"index:idx_comment_composite"`
|
||||
TableID uint64 `gorm:"index:idx_comment_composite"`
|
||||
UserID uint64 `gorm:"index"`
|
||||
User User
|
||||
User User `json:"-"`
|
||||
Message string
|
||||
CreatedAt time.Time
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
|
@ -29,6 +29,16 @@ var CommentableTables = map[string]interface{}{
|
|||
"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).
|
||||
func (c *Comment) Preload() *gorm.DB {
|
||||
return DB.Preload("User.ProfilePhoto")
|
||||
|
@ -94,21 +104,27 @@ func CountCommentsReceived(user *User) int64 {
|
|||
}
|
||||
|
||||
// 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 (
|
||||
cs = []*Comment{}
|
||||
query = (&Comment{}).Preload()
|
||||
blockedUserIDs = BlockedUserIDs(user)
|
||||
wheres = []string{}
|
||||
placeholders = []interface{}{}
|
||||
cs = []*Comment{}
|
||||
query = (&Comment{}).Preload()
|
||||
wheres = []string{}
|
||||
placeholders = []interface{}{}
|
||||
)
|
||||
|
||||
wheres = append(wheres, "table_name = ? AND table_id = ?")
|
||||
placeholders = append(placeholders, tableName, tableID)
|
||||
|
||||
if len(blockedUserIDs) > 0 {
|
||||
wheres = append(wheres, "user_id NOT IN ?")
|
||||
placeholders = append(placeholders, blockedUserIDs)
|
||||
if !noBlockLists {
|
||||
blockedUserIDs := BlockedUserIDs(user)
|
||||
if len(blockedUserIDs) > 0 {
|
||||
wheres = append(wheres, "user_id NOT IN ?")
|
||||
placeholders = append(placeholders, blockedUserIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// Don't show comments from banned or disabled accounts.
|
||||
|
@ -172,6 +188,14 @@ func FindPageByComment(user *User, comment *Comment, pageSize int) (int, error)
|
|||
for i, cid := range allCommentIDs {
|
||||
if cid == comment.ID {
|
||||
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 {
|
||||
page = 1
|
||||
}
|
||||
|
|
|
@ -89,9 +89,16 @@ func MapCommentPhotos(comments []*Comment) (CommentPhotoMap, error) {
|
|||
)
|
||||
|
||||
for _, c := range comments {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
IDs = append(IDs, c.ID)
|
||||
}
|
||||
|
||||
if len(IDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
res := DB.Model(&CommentPhoto{}).Where("comment_id IN ?", IDs).Find(&ps)
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
|
@ -127,7 +134,15 @@ func GetOrphanedCommentPhotos() ([]*CommentPhoto, int64, error) {
|
|||
ps = []*CommentPhoto{}
|
||||
)
|
||||
|
||||
query := DB.Model(&CommentPhoto{}).Where("comment_id = 0 AND created_at < ?", cutoff)
|
||||
query := DB.Model(&CommentPhoto{}).Where(`
|
||||
(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)
|
||||
res := query.Limit(500).Find(&ps)
|
||||
if res.Error != nil {
|
||||
|
|
|
@ -3,6 +3,7 @@ package deletion
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"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/photo"
|
||||
|
@ -12,6 +13,17 @@ import (
|
|||
func DeleteUser(user *models.User) error {
|
||||
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.
|
||||
type remover struct {
|
||||
Step string
|
||||
|
@ -24,6 +36,8 @@ func DeleteUser(user *models.User) error {
|
|||
// Tables to remove. In case of any unexpected DB errors, these tables are ordered
|
||||
// to remove the "safest" fields first.
|
||||
var todo = []remover{
|
||||
{"Admin group memberships", DeleteAdminGroupUsers},
|
||||
{"Disown User Forums", DisownForums},
|
||||
{"Notifications", DeleteNotifications},
|
||||
{"Likes", DeleteLikes},
|
||||
{"Threads", DeleteForumThreads},
|
||||
|
@ -41,6 +55,11 @@ func DeleteUser(user *models.User) error {
|
|||
{"Two Factor", DeleteTwoFactor},
|
||||
{"Profile Fields", DeleteProfile},
|
||||
{"User Notes", DeleteUserNotes},
|
||||
{"Change Logs", DeleteChangeLogs},
|
||||
{"IP Addresses", DeleteIPAddresses},
|
||||
{"Push Notifications", DeletePushNotifications},
|
||||
{"Forum Memberships", DeleteForumMemberships},
|
||||
{"Usage Statistics", DeleteUsageStatistics},
|
||||
}
|
||||
for _, item := range todo {
|
||||
if err := item.Fn(user.ID); err != nil {
|
||||
|
@ -52,6 +71,16 @@ func DeleteUser(user *models.User) error {
|
|||
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.
|
||||
func DeleteUserPhotos(userID uint64) error {
|
||||
log.Error("DeleteUser: BEGIN DeleteUserPhotos(%d)", userID)
|
||||
|
@ -327,3 +356,64 @@ func DeleteUserNotes(userID uint64) error {
|
|||
).Delete(&models.UserNote{})
|
||||
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
|
||||
}
|
||||
|
|
196
pkg/models/demographic/demographic.go
Normal file
196
pkg/models/demographic/demographic.go
Normal file
|
@ -0,0 +1,196 @@
|
|||
// Package demographic handles periodic report pulling for high level website statistics.
|
||||
//
|
||||
// It powers the home page and insights page, where a prospective new user can get a peek inside
|
||||
// the website to see the split between regular vs. explicit content and membership statistics.
|
||||
//
|
||||
// These database queries could get slow so the demographics are pulled and cached in this package.
|
||||
package demographic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/utility"
|
||||
)
|
||||
|
||||
// Demographic is the top level container struct with all the insights needed for front-end display.
|
||||
type Demographic struct {
|
||||
Computed bool
|
||||
LastUpdated time.Time
|
||||
Photo Photo
|
||||
People People
|
||||
}
|
||||
|
||||
// Photo statistics show the split between explicit and non-explicit content.
|
||||
type Photo struct {
|
||||
Total int64
|
||||
NonExplicit int64
|
||||
Explicit int64
|
||||
}
|
||||
|
||||
// People statistics.
|
||||
type People struct {
|
||||
Total int64
|
||||
ExplicitOptIn int64
|
||||
ExplicitPhoto int64
|
||||
ByAgeRange map[string]int64
|
||||
ByGender map[string]int64
|
||||
ByOrientation map[string]int64
|
||||
}
|
||||
|
||||
// MemberDemographic of members.
|
||||
type MemberDemographic struct {
|
||||
Label string // e.g. age range "18-25" or gender
|
||||
Count int64
|
||||
Percent string
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic calculation methods on the above types (percentages, etc.)
|
||||
*/
|
||||
|
||||
func (d Demographic) PrettyPrint() string {
|
||||
b, err := json.MarshalIndent(d, "", "\t")
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (p Photo) PercentExplicit() string {
|
||||
if p.Total == 0 {
|
||||
return "0"
|
||||
}
|
||||
return utility.FormatFloatToPrecision((float64(p.Explicit)/float64(p.Total))*100, 1)
|
||||
}
|
||||
|
||||
func (p Photo) PercentNonExplicit() string {
|
||||
if p.Total == 0 {
|
||||
return "0"
|
||||
}
|
||||
return utility.FormatFloatToPrecision((float64(p.NonExplicit)/float64(p.Total))*100, 1)
|
||||
}
|
||||
|
||||
func (p People) PercentExplicit() string {
|
||||
if p.Total == 0 {
|
||||
return "0"
|
||||
}
|
||||
return utility.FormatFloatToPrecision((float64(p.ExplicitOptIn)/float64(p.Total))*100, 1)
|
||||
}
|
||||
|
||||
func (p People) PercentExplicitPhoto() string {
|
||||
if p.Total == 0 {
|
||||
return "0"
|
||||
}
|
||||
return utility.FormatFloatToPrecision((float64(p.ExplicitPhoto)/float64(p.Total))*100, 1)
|
||||
}
|
||||
|
||||
func (p People) IterAgeRanges() []MemberDemographic {
|
||||
var (
|
||||
result = []MemberDemographic{}
|
||||
values = []string{}
|
||||
unique = map[string]struct{}{}
|
||||
)
|
||||
|
||||
for age := range p.ByAgeRange {
|
||||
if _, ok := unique[age]; !ok {
|
||||
values = append(values, age)
|
||||
}
|
||||
unique[age] = struct{}{}
|
||||
}
|
||||
|
||||
sort.Strings(values)
|
||||
for _, age := range values {
|
||||
var (
|
||||
count = p.ByAgeRange[age]
|
||||
pct float64
|
||||
)
|
||||
if p.Total > 0 {
|
||||
pct = ((float64(count) / float64(p.Total)) * 100)
|
||||
}
|
||||
|
||||
result = append(result, MemberDemographic{
|
||||
Label: age,
|
||||
Count: p.ByAgeRange[age],
|
||||
Percent: utility.FormatFloatToPrecision(pct, 1),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (p People) IterGenders() []MemberDemographic {
|
||||
var (
|
||||
result = []MemberDemographic{}
|
||||
values = append(config.Gender, "")
|
||||
unique = map[string]struct{}{}
|
||||
)
|
||||
|
||||
for _, option := range values {
|
||||
unique[option] = struct{}{}
|
||||
}
|
||||
|
||||
for gender := range p.ByGender {
|
||||
if _, ok := unique[gender]; !ok {
|
||||
values = append(values, gender)
|
||||
unique[gender] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, gender := range values {
|
||||
var (
|
||||
count = p.ByGender[gender]
|
||||
pct float64
|
||||
)
|
||||
if p.Total > 0 {
|
||||
pct = ((float64(count) / float64(p.Total)) * 100)
|
||||
}
|
||||
|
||||
result = append(result, MemberDemographic{
|
||||
Label: gender,
|
||||
Count: p.ByGender[gender],
|
||||
Percent: utility.FormatFloatToPrecision(pct, 1),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (p People) IterOrientations() []MemberDemographic {
|
||||
var (
|
||||
result = []MemberDemographic{}
|
||||
values = append(config.Orientation, "")
|
||||
unique = map[string]struct{}{}
|
||||
)
|
||||
|
||||
for _, option := range values {
|
||||
unique[option] = struct{}{}
|
||||
}
|
||||
|
||||
for orientation := range p.ByOrientation {
|
||||
if _, ok := unique[orientation]; !ok {
|
||||
values = append(values, orientation)
|
||||
unique[orientation] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, gender := range values {
|
||||
var (
|
||||
count = p.ByOrientation[gender]
|
||||
pct float64
|
||||
)
|
||||
if p.Total > 0 {
|
||||
pct = ((float64(count) / float64(p.Total)) * 100)
|
||||
}
|
||||
|
||||
result = append(result, MemberDemographic{
|
||||
Label: gender,
|
||||
Count: p.ByOrientation[gender],
|
||||
Percent: utility.FormatFloatToPrecision(pct, 1),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
302
pkg/models/demographic/queries.go
Normal file
302
pkg/models/demographic/queries.go
Normal file
|
@ -0,0 +1,302 @@
|
|||
package demographic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
)
|
||||
|
||||
// Cached statistics (in case the queries are heavy to hit too often).
|
||||
var (
|
||||
cachedDemographic Demographic
|
||||
cacheMu sync.Mutex
|
||||
)
|
||||
|
||||
// Get the current cached demographics result.
|
||||
func Get() (Demographic, error) {
|
||||
// Do we have the results cached?
|
||||
var result = cachedDemographic
|
||||
if !result.Computed || time.Since(result.LastUpdated) > config.DemographicsCacheTTL {
|
||||
cacheMu.Lock()
|
||||
defer cacheMu.Unlock()
|
||||
|
||||
// If we have a race of threads: e.g. one request is pulling the stats and the second is locked.
|
||||
// Check if we have an updated result from the first thread.
|
||||
if time.Since(cachedDemographic.LastUpdated) < config.DemographicsCacheTTL {
|
||||
return cachedDemographic, nil
|
||||
}
|
||||
|
||||
// Get the latest.
|
||||
res, err := Generate()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
cachedDemographic = res
|
||||
}
|
||||
|
||||
return cachedDemographic, nil
|
||||
}
|
||||
|
||||
// Refresh the demographics cache, pulling fresh results from the database every time.
|
||||
func Refresh() (Demographic, error) {
|
||||
cacheMu.Lock()
|
||||
cachedDemographic = Demographic{}
|
||||
cacheMu.Unlock()
|
||||
return Get()
|
||||
}
|
||||
|
||||
// Generate the demographics result.
|
||||
func Generate() (Demographic, error) {
|
||||
if !config.Current.Database.IsPostgres {
|
||||
return cachedDemographic, errors.New("this feature requires a PostgreSQL database")
|
||||
}
|
||||
|
||||
result := Demographic{
|
||||
Computed: true,
|
||||
LastUpdated: time.Now(),
|
||||
Photo: PhotoStatistics(),
|
||||
People: PeopleStatistics(),
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PeopleStatistics pulls various metrics about users of the website.
|
||||
func PeopleStatistics() People {
|
||||
var result = People{
|
||||
ByAgeRange: map[string]int64{},
|
||||
ByGender: map[string]int64{"": 0},
|
||||
ByOrientation: map[string]int64{"": 0},
|
||||
}
|
||||
|
||||
type record struct {
|
||||
MetricType string
|
||||
MetricValue string
|
||||
MetricCount int64
|
||||
}
|
||||
var records []record
|
||||
res := models.DB.Raw(`
|
||||
-- Users who opt in/out of explicit content
|
||||
WITH subquery_explicit AS (
|
||||
SELECT
|
||||
SUM(CASE WHEN explicit IS TRUE THEN 1 ELSE 0 END) AS explicit_count,
|
||||
SUM(CASE WHEN explicit IS NOT TRUE THEN 1 ELSE 0 END) AS non_explicit_count
|
||||
FROM users
|
||||
WHERE users.status = 'active'
|
||||
AND users.certified IS TRUE
|
||||
),
|
||||
|
||||
-- Users who share at least one explicit photo on public
|
||||
subquery_explicit_photo AS (
|
||||
SELECT
|
||||
COUNT(*) AS user_count
|
||||
FROM users
|
||||
WHERE users.status = 'active'
|
||||
AND users.certified IS TRUE
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM photos
|
||||
WHERE photos.user_id = users.id
|
||||
AND photos.explicit IS TRUE
|
||||
AND photos.visibility = 'public'
|
||||
)
|
||||
),
|
||||
|
||||
-- User counts by age
|
||||
subquery_ages AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 0 AND 25 THEN '18-25'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 26 and 35 THEN '26-35'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 36 and 45 THEN '36-45'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 46 and 55 THEN '46-55'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 56 and 65 THEN '56-65'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 66 and 75 THEN '66-75'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 76 and 85 THEN '76-85'
|
||||
ELSE '86+'
|
||||
END AS age_range,
|
||||
COUNT(*) AS user_count
|
||||
FROM
|
||||
users
|
||||
WHERE users.status = 'active'
|
||||
AND users.certified IS TRUE
|
||||
GROUP BY
|
||||
CASE
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 0 AND 25 THEN '18-25'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 26 and 35 THEN '26-35'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 36 and 45 THEN '36-45'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 46 and 55 THEN '46-55'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 56 and 65 THEN '56-65'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 66 and 75 THEN '66-75'
|
||||
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 76 and 85 THEN '76-85'
|
||||
ELSE '86+'
|
||||
END
|
||||
),
|
||||
|
||||
-- User counts by gender
|
||||
subquery_gender AS (
|
||||
SELECT
|
||||
profile_fields.value AS gender,
|
||||
COUNT(*) AS user_count
|
||||
FROM users
|
||||
JOIN profile_fields ON profile_fields.user_id = users.id
|
||||
WHERE users.status = 'active'
|
||||
AND users.certified IS TRUE
|
||||
AND profile_fields.name = 'gender'
|
||||
GROUP BY profile_fields.value
|
||||
),
|
||||
|
||||
-- User counts by orientation
|
||||
subquery_orientation AS (
|
||||
SELECT
|
||||
profile_fields.value AS orientation,
|
||||
COUNT(*) AS user_count
|
||||
FROM users
|
||||
JOIN profile_fields ON profile_fields.user_id = users.id
|
||||
WHERE users.status = 'active'
|
||||
AND users.certified IS TRUE
|
||||
AND profile_fields.name = 'orientation'
|
||||
GROUP BY profile_fields.value
|
||||
)
|
||||
|
||||
SELECT
|
||||
'ExplicitCount' AS metric_type,
|
||||
'explicit' AS metric_value,
|
||||
explicit_count AS metric_count
|
||||
FROM subquery_explicit
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'ExplicitPhotoCount' AS metric_type,
|
||||
'count' AS metric_value,
|
||||
user_count AS metric_count
|
||||
FROM subquery_explicit_photo
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'ExplicitCount' AS metric_type,
|
||||
'non_explicit' AS metric_value,
|
||||
non_explicit_count AS metric_count
|
||||
FROM subquery_explicit
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'AgeCounts' AS metric_type,
|
||||
age_range AS metric_value,
|
||||
user_count AS metric_count
|
||||
FROM subquery_ages
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'GenderCount' AS metric_type,
|
||||
gender AS metric_value,
|
||||
user_count AS metric_count
|
||||
FROM subquery_gender
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'OrientationCount' AS metric_type,
|
||||
orientation AS metric_value,
|
||||
user_count AS metric_count
|
||||
FROM subquery_orientation
|
||||
`).Scan(&records)
|
||||
if res.Error != nil {
|
||||
log.Error("PeopleStatistics: %s", res.Error)
|
||||
return result
|
||||
}
|
||||
|
||||
// Ingest the records.
|
||||
var (
|
||||
totalWithAge int64 // will be the total count of users since age is required
|
||||
totalWithGender int64
|
||||
totalWithOrientation int64
|
||||
)
|
||||
for _, row := range records {
|
||||
switch row.MetricType {
|
||||
case "ExplicitCount":
|
||||
result.Total += row.MetricCount
|
||||
if row.MetricValue == "explicit" {
|
||||
result.ExplicitOptIn = row.MetricCount
|
||||
}
|
||||
case "ExplicitPhotoCount":
|
||||
result.ExplicitPhoto = row.MetricCount
|
||||
case "AgeCounts":
|
||||
if _, ok := result.ByAgeRange[row.MetricValue]; !ok {
|
||||
result.ByAgeRange[row.MetricValue] = 0
|
||||
}
|
||||
result.ByAgeRange[row.MetricValue] += row.MetricCount
|
||||
totalWithAge += row.MetricCount
|
||||
case "GenderCount":
|
||||
if _, ok := result.ByGender[row.MetricValue]; !ok {
|
||||
result.ByGender[row.MetricValue] = 0
|
||||
}
|
||||
result.ByGender[row.MetricValue] += row.MetricCount
|
||||
totalWithGender += row.MetricCount
|
||||
case "OrientationCount":
|
||||
if _, ok := result.ByOrientation[row.MetricValue]; !ok {
|
||||
result.ByOrientation[row.MetricValue] = 0
|
||||
}
|
||||
result.ByOrientation[row.MetricValue] += row.MetricCount
|
||||
totalWithOrientation += row.MetricCount
|
||||
}
|
||||
}
|
||||
|
||||
// Gender and Orientation: pad out the "no answer" selection with the count of users
|
||||
// who had no profile_fields stored in the DB at all.
|
||||
result.ByOrientation[""] += (totalWithAge - totalWithOrientation)
|
||||
result.ByGender[""] += (totalWithAge - totalWithGender)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// PhotoStatistics gets info about photo usage on the website.
|
||||
//
|
||||
// Counts of Explicit vs. Non-Explicit photos.
|
||||
func PhotoStatistics() Photo {
|
||||
var result Photo
|
||||
type record struct {
|
||||
Explicit bool
|
||||
C int64
|
||||
}
|
||||
var records []record
|
||||
|
||||
res := models.DB.Raw(`
|
||||
SELECT
|
||||
photos.explicit,
|
||||
count(photos.id) AS c
|
||||
FROM
|
||||
photos
|
||||
JOIN users ON (photos.user_id = users.id)
|
||||
WHERE photos.visibility = 'public'
|
||||
AND photos.gallery IS TRUE
|
||||
AND users.certified IS TRUE
|
||||
AND users.status = 'active'
|
||||
GROUP BY photos.explicit
|
||||
ORDER BY c DESC
|
||||
`).Scan(&records)
|
||||
if res.Error != nil {
|
||||
log.Error("PhotoStatistics: %s", res.Error)
|
||||
return result
|
||||
}
|
||||
|
||||
for _, row := range records {
|
||||
result.Total += row.C
|
||||
if row.Explicit {
|
||||
result.Explicit += row.C
|
||||
} else {
|
||||
result.NonExplicit += row.C
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
|
@ -19,28 +19,33 @@ func ExportModels(zw *zip.Writer, user *models.User) error {
|
|||
// List of tables to export. Keep the ordering in sync with
|
||||
// the AutoMigrate() calls in ../models.go
|
||||
var todo = []task{
|
||||
{"User", ExportUserTable},
|
||||
// Note: AdminGroup info is eager-loaded in User export
|
||||
{"Block", ExportBlockTable},
|
||||
{"CertificationPhoto", ExportCertificationPhotoTable},
|
||||
{"ChangeLog", ExportChangeLogTable},
|
||||
{"Comment", ExportCommentTable},
|
||||
{"CommentPhoto", ExportCommentPhotoTable},
|
||||
{"Feedback", ExportFeedbackTable},
|
||||
{"ForumMembership", ExportForumMembershipTable},
|
||||
{"Friend", ExportFriendTable},
|
||||
{"Forum", ExportForumTable},
|
||||
{"IPAddress", ExportIPAddressTable},
|
||||
{"Like", ExportLikeTable},
|
||||
{"Message", ExportMessageTable},
|
||||
{"Notification", ExportNotificationTable},
|
||||
{"ProfileField", ExportProfileFieldTable},
|
||||
{"Photo", ExportPhotoTable},
|
||||
{"PrivatePhoto", ExportPrivatePhotoTable},
|
||||
{"CertificationPhoto", ExportCertificationPhotoTable},
|
||||
{"Message", ExportMessageTable},
|
||||
{"Friend", ExportFriendTable},
|
||||
{"Block", ExportBlockTable},
|
||||
{"Feedback", ExportFeedbackTable},
|
||||
{"Forum", ExportForumTable},
|
||||
{"Thread", ExportThreadTable},
|
||||
{"Comment", ExportCommentTable},
|
||||
{"Like", ExportLikeTable},
|
||||
{"Notification", ExportNotificationTable},
|
||||
{"Subscription", ExportSubscriptionTable},
|
||||
{"CommentPhoto", ExportCommentPhotoTable},
|
||||
// Note: Poll table is eager-loaded in Thread export
|
||||
{"PollVote", ExportPollVoteTable},
|
||||
// Note: AdminGroup info is eager-loaded in User export
|
||||
{"PrivatePhoto", ExportPrivatePhotoTable},
|
||||
{"PushNotification", ExportPushNotificationTable},
|
||||
{"Subscription", ExportSubscriptionTable},
|
||||
{"Thread", ExportThreadTable},
|
||||
{"TwoFactor", ExportTwoFactorTable},
|
||||
{"UsageStatistic", ExportUsageStatisticTable},
|
||||
{"User", ExportUserTable},
|
||||
{"UserLocation", ExportUserLocationTable},
|
||||
{"UserNote", ExportUserNoteTable},
|
||||
{"TwoFactor", ExportTwoFactorTable},
|
||||
}
|
||||
for _, item := range todo {
|
||||
log.Info("Exporting data model: %s", item.Step)
|
||||
|
@ -383,6 +388,21 @@ func ExportUserNoteTable(zw *zip.Writer, user *models.User) error {
|
|||
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 {
|
||||
var (
|
||||
items = []*models.UserLocation{}
|
||||
|
@ -412,3 +432,63 @@ func ExportTwoFactorTable(zw *zip.Writer, user *models.User) error {
|
|||
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
@ -11,6 +12,7 @@ import (
|
|||
type Feedback struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
UserID uint64 `gorm:"index"` // if logged-in user posted this
|
||||
AboutUserID uint64 // associated 'about' user (e.g., owner of a reported photo)
|
||||
Acknowledged bool `gorm:"index"` // admin dashboard "read" status
|
||||
Intent string
|
||||
Subject string
|
||||
|
@ -45,7 +47,7 @@ func CountUnreadFeedback() int64 {
|
|||
}
|
||||
|
||||
// PaginateFeedback
|
||||
func PaginateFeedback(acknowledged bool, intent string, pager *Pagination) ([]*Feedback, error) {
|
||||
func PaginateFeedback(acknowledged bool, intent, subject string, search *Search, pager *Pagination) ([]*Feedback, error) {
|
||||
var (
|
||||
fb = []*Feedback{}
|
||||
wheres = []string{}
|
||||
|
@ -60,6 +62,23 @@ func PaginateFeedback(acknowledged bool, intent string, pager *Pagination) ([]*F
|
|||
placeholders = append(placeholders, intent)
|
||||
}
|
||||
|
||||
if subject != "" {
|
||||
wheres = append(wheres, "subject = ?")
|
||||
placeholders = append(placeholders, subject)
|
||||
}
|
||||
|
||||
// Search terms.
|
||||
for _, term := range search.Includes {
|
||||
var ilike = "%" + strings.ToLower(term) + "%"
|
||||
wheres = append(wheres, "message ILIKE ?")
|
||||
placeholders = append(placeholders, ilike)
|
||||
}
|
||||
for _, term := range search.Excludes {
|
||||
var ilike = "%" + strings.ToLower(term) + "%"
|
||||
wheres = append(wheres, "message NOT ILIKE ?")
|
||||
placeholders = append(placeholders, ilike)
|
||||
}
|
||||
|
||||
query := DB.Where(
|
||||
strings.Join(wheres, " AND "),
|
||||
placeholders...,
|
||||
|
@ -81,19 +100,49 @@ func PaginateFeedback(acknowledged bool, intent string, pager *Pagination) ([]*F
|
|||
// It returns reports where table_name=users and their user ID, or where table_name=photos and about any
|
||||
// of their current photo IDs. Additionally, it will look for chat room reports which were about their
|
||||
// username.
|
||||
func PaginateFeedbackAboutUser(user *User, pager *Pagination) ([]*Feedback, error) {
|
||||
//
|
||||
// The 'show' parameter applies some basic filter choices:
|
||||
//
|
||||
// - Blank string (default) = all reports From or About this user
|
||||
// - "about" = all reports About this user (by table_name=users table_id=userID, or table_name=photos
|
||||
// for any of their existing photo IDs)
|
||||
// - "from" = all reports From this user (where reporting user_id is the user's ID)
|
||||
// - "fuzzy" = fuzzy full text search on all reports that contain the user's username.
|
||||
func PaginateFeedbackAboutUser(user *User, show string, pager *Pagination) ([]*Feedback, error) {
|
||||
var (
|
||||
fb = []*Feedback{}
|
||||
photoIDs, _ = user.AllPhotoIDs()
|
||||
wheres = []string{}
|
||||
placeholders = []interface{}{}
|
||||
like = "%" + user.Username + "%"
|
||||
)
|
||||
|
||||
wheres = append(wheres, `
|
||||
(table_name = 'users' AND table_id = ?) OR
|
||||
(table_name = 'photos' AND table_id IN ?)
|
||||
`)
|
||||
placeholders = append(placeholders, user.ID, photoIDs)
|
||||
// How to apply the search filters?
|
||||
switch show {
|
||||
case "about":
|
||||
wheres = append(wheres, `
|
||||
about_user_id = ? OR
|
||||
(table_name = 'users' AND table_id = ?) OR
|
||||
(table_name = 'photos' AND table_id IN ?)
|
||||
`)
|
||||
placeholders = append(placeholders, user.ID, user.ID, photoIDs)
|
||||
case "from":
|
||||
wheres = append(wheres, "user_id = ?")
|
||||
placeholders = append(placeholders, user.ID)
|
||||
case "fuzzy":
|
||||
wheres = append(wheres, "message LIKE ?")
|
||||
placeholders = append(placeholders, like)
|
||||
default:
|
||||
// Default=everything.
|
||||
wheres = append(wheres, `
|
||||
user_id = ? OR
|
||||
about_user_id = ? OR
|
||||
(table_name = 'users' AND table_id = ?) OR
|
||||
(table_name = 'photos' AND table_id IN ?) OR
|
||||
message LIKE ?
|
||||
`)
|
||||
placeholders = append(placeholders, user.ID, user.ID, user.ID, photoIDs, like)
|
||||
}
|
||||
|
||||
query := DB.Where(
|
||||
strings.Join(wheres, " AND "),
|
||||
|
@ -111,6 +160,22 @@ func PaginateFeedbackAboutUser(user *User, pager *Pagination) ([]*Feedback, erro
|
|||
return fb, result.Error
|
||||
}
|
||||
|
||||
// DistinctFeedbackSubjects returns the distinct subjects on feedback & reports.
|
||||
func DistinctFeedbackSubjects() []string {
|
||||
var results = []string{}
|
||||
query := DB.Model(&Feedback{}).
|
||||
Select("DISTINCT feedbacks.subject").
|
||||
Group("feedbacks.subject").
|
||||
Find(&results)
|
||||
if query.Error != nil {
|
||||
log.Error("DistinctFeedbackSubjects: %s", query.Error)
|
||||
return nil
|
||||
}
|
||||
|
||||
sort.Strings(results)
|
||||
return results
|
||||
}
|
||||
|
||||
// CreateFeedback saves a new Feedback row to the DB.
|
||||
func CreateFeedback(fb *Feedback) error {
|
||||
result := DB.Create(fb)
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
|
@ -20,14 +21,14 @@ type Forum struct {
|
|||
Explicit bool `gorm:"index"`
|
||||
Privileged bool
|
||||
PermitPhotos bool
|
||||
InnerCircle bool
|
||||
Private bool `gorm:"index"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Preload related tables for the forum (classmethod).
|
||||
func (f *Forum) Preload() *gorm.DB {
|
||||
return DB.Preload("Owner")
|
||||
return DB.Preload("Owner").Preload("Owner.ProfilePhoto")
|
||||
}
|
||||
|
||||
// GetForum by ID.
|
||||
|
@ -69,6 +70,13 @@ func ForumByFragment(fragment string) (*Forum, 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.
|
||||
|
||||
|
@ -77,8 +85,15 @@ Parameters:
|
|||
- userID: of who is looking
|
||||
- categories: optional, filter within categories
|
||||
- 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, pager *Pagination) ([]*Forum, error) {
|
||||
func PaginateForums(user *User, categories []string, search *Search, subscribed bool, pager *Pagination) ([]*Forum, error) {
|
||||
var (
|
||||
fs = []*Forum{}
|
||||
query = (&Forum{}).Preload()
|
||||
|
@ -96,9 +111,55 @@ func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Foru
|
|||
wheres = append(wheres, "explicit = false")
|
||||
}
|
||||
|
||||
// Hide circle forums if the user isn't in the circle.
|
||||
if !user.IsInnerCircle() {
|
||||
wheres = append(wheres, "inner_circle is not true")
|
||||
// Hide private forums except for admins and approved users.
|
||||
if !user.IsAdmin {
|
||||
wheres = append(wheres, `
|
||||
(
|
||||
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?
|
||||
|
@ -109,6 +170,43 @@ func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Foru
|
|||
)
|
||||
}
|
||||
|
||||
// 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.Model(&Forum{}).Count(&pager.Total)
|
||||
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
|
||||
|
@ -116,20 +214,44 @@ func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Foru
|
|||
}
|
||||
|
||||
// PaginateOwnedForums returns forums the user owns (or all forums to admins).
|
||||
func PaginateOwnedForums(userID uint64, isAdmin bool, pager *Pagination) ([]*Forum, error) {
|
||||
func PaginateOwnedForums(userID uint64, isAdmin bool, categories []string, search *Search, pager *Pagination) ([]*Forum, error) {
|
||||
var (
|
||||
fs = []*Forum{}
|
||||
query = (&Forum{}).Preload()
|
||||
fs = []*Forum{}
|
||||
query = (&Forum{}).Preload()
|
||||
wheres = []string{}
|
||||
placeholders = []interface{}{}
|
||||
)
|
||||
|
||||
// Users see only their owned forums.
|
||||
if !isAdmin {
|
||||
query = query.Where(
|
||||
"owner_id = ?",
|
||||
userID,
|
||||
)
|
||||
wheres = append(wheres, "owner_id = ?")
|
||||
placeholders = append(placeholders, userID)
|
||||
}
|
||||
|
||||
query = query.Order(pager.Sort)
|
||||
if len(categories) > 0 {
|
||||
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)
|
||||
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
|
||||
return fs, result.Error
|
||||
|
@ -159,6 +281,15 @@ func CategorizeForums(fs []*Forum, categories []string) []*CategorizedForum {
|
|||
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.
|
||||
for i, category := range categories {
|
||||
result = append(result, &CategorizedForum{
|
||||
|
|
291
pkg/models/forum_membership.go
Normal file
291
pkg/models/forum_membership.go
Normal file
|
@ -0,0 +1,291 @@
|
|||
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
|
||||
}
|
67
pkg/models/forum_quota.go
Normal file
67
pkg/models/forum_quota.go
Normal file
|
@ -0,0 +1,67 @@
|
|||
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
|
||||
}
|
|
@ -1,9 +1,11 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
)
|
||||
|
||||
|
@ -20,13 +22,19 @@ type RecentPost struct {
|
|||
}
|
||||
|
||||
// PaginateRecentPosts returns all of the comments on a forum paginated.
|
||||
func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]*RecentPost, error) {
|
||||
func PaginateRecentPosts(user *User, categories []string, subscribed, allComments bool, pager *Pagination) ([]*RecentPost, error) {
|
||||
var (
|
||||
result = []*RecentPost{}
|
||||
query = (&Comment{}).Preload()
|
||||
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{}{}
|
||||
comment_wheres = []string{"table_name = 'threads'"}
|
||||
comment_ph = []interface{}{}
|
||||
)
|
||||
|
||||
if len(categories) > 0 {
|
||||
|
@ -39,19 +47,47 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
|
|||
wheres = append(wheres, "forums.explicit = false")
|
||||
}
|
||||
|
||||
// Circle membership.
|
||||
if !user.IsInnerCircle() {
|
||||
wheres = append(wheres, "forums.inner_circle is not true")
|
||||
// Private forums.
|
||||
if !user.IsAdmin {
|
||||
wheres = append(wheres, `
|
||||
(
|
||||
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?
|
||||
if len(blockedUserIDs) > 0 {
|
||||
wheres = append(wheres, "comments.user_id NOT IN ?")
|
||||
placeholders = append(placeholders, blockedUserIDs)
|
||||
comment_wheres = append(comment_wheres, "comments.user_id NOT IN ?")
|
||||
comment_ph = append(comment_ph, blockedUserIDs)
|
||||
}
|
||||
|
||||
// Don't show comments from banned or disabled accounts.
|
||||
wheres = append(wheres, `
|
||||
comment_wheres = append(comment_wheres, `
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM users
|
||||
|
@ -61,30 +97,25 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
|
|||
`)
|
||||
|
||||
// Get the page of recent forum comment IDs of all time.
|
||||
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")
|
||||
var scan NewestForumPostsScanner
|
||||
|
||||
// Get the total for the pager and scan the page of ID sets.
|
||||
query.Model(&Comment{}).Count(&pager.Total)
|
||||
query = query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&scan)
|
||||
if query.Error != nil {
|
||||
return nil, query.Error
|
||||
// Deduplicate forum threads: if one thread is BLOWING UP with replies, we should only
|
||||
// mention the thread once and show the newest comment so it doesn't spam the whole page.
|
||||
if config.Current.Database.IsPostgres && !allComments {
|
||||
// Note: only Postgres supports this function (SELECT DISTINCT ON).
|
||||
if res, err := ScanLatestForumCommentsPerThread(wheres, comment_wheres, placeholders, comment_ph, pager); err != nil {
|
||||
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.
|
||||
|
@ -181,6 +212,13 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
|
|||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
rc.Forum = f
|
||||
}
|
||||
|
@ -192,3 +230,140 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
|
|||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -81,7 +81,7 @@ type ForumSearchFilters struct {
|
|||
}
|
||||
|
||||
// SearchForum searches the forum.
|
||||
func SearchForum(user *User, search *Search, filters ForumSearchFilters, pager *Pagination) ([]*Comment, error) {
|
||||
func SearchForum(user *User, categories []string, search *Search, filters ForumSearchFilters, pager *Pagination) ([]*Comment, error) {
|
||||
var (
|
||||
coms = []*Comment{}
|
||||
query = (&Comment{}).Preload()
|
||||
|
@ -90,14 +90,19 @@ func SearchForum(user *User, search *Search, filters ForumSearchFilters, pager *
|
|||
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.
|
||||
if !user.Explicit && !user.IsAdmin {
|
||||
wheres = append(wheres, "forums.explicit = false")
|
||||
}
|
||||
|
||||
// Circle membership.
|
||||
if !user.IsInnerCircle() {
|
||||
wheres = append(wheres, "forums.inner_circle is not true")
|
||||
// Private forums.
|
||||
if !user.IsAdmin {
|
||||
wheres = append(wheres, "forums.private is not true")
|
||||
}
|
||||
|
||||
// Blocked users?
|
||||
|
|
|
@ -230,7 +230,6 @@ func (ts ForumStatsMap) generateRecentPosts(IDs []uint64) {
|
|||
"comments",
|
||||
).Select(
|
||||
"table_id AS thread_id, id AS comment_id",
|
||||
// "forum_id, id AS thread_id, updated_at",
|
||||
).Where(
|
||||
`table_name='threads' AND table_id IN ?
|
||||
AND updated_at = (SELECT MAX(updated_at)
|
||||
|
|
|
@ -6,7 +6,6 @@ import (
|
|||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Friend table.
|
||||
|
@ -17,7 +16,7 @@ type Friend struct {
|
|||
Approved bool `gorm:"index"`
|
||||
Ignored bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
UpdatedAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
// AddFriend sends a friend request or accepts one if there was already a pending one.
|
||||
|
@ -249,11 +248,19 @@ func FriendIDsInCircleAreExplicit(userId uint64) []uint64 {
|
|||
|
||||
// CountFriendRequests gets a count of pending requests for the user.
|
||||
func CountFriendRequests(userID uint64) (int64, error) {
|
||||
var count int64
|
||||
var (
|
||||
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(
|
||||
"target_user_id = ? AND approved = ? AND ignored IS NOT true",
|
||||
userID,
|
||||
false,
|
||||
strings.Join(wheres, " AND "),
|
||||
placeholders...,
|
||||
).Model(&Friend{}).Count(&count)
|
||||
return count, result.Error
|
||||
}
|
||||
|
@ -262,7 +269,7 @@ func CountFriendRequests(userID uint64) (int64, error) {
|
|||
func CountIgnoredFriendRequests(userID uint64) (int64, error) {
|
||||
var count int64
|
||||
result := DB.Where(
|
||||
"target_user_id = ? AND approved = ? AND ignored = ?",
|
||||
"target_user_id = ? AND approved = ? AND ignored = ? AND EXISTS (SELECT 1 FROM users WHERE users.id = friends.source_user_id AND users.status = 'active')",
|
||||
userID,
|
||||
false,
|
||||
true,
|
||||
|
@ -295,38 +302,77 @@ have sent and have not been answered.
|
|||
func PaginateFriends(user *User, requests bool, sent bool, ignored bool, pager *Pagination) ([]*User, error) {
|
||||
// We paginate over the Friend table.
|
||||
var (
|
||||
fs = []*Friend{}
|
||||
userIDs = []uint64{}
|
||||
query *gorm.DB
|
||||
fs = []*Friend{}
|
||||
userIDs = []uint64{}
|
||||
blockedUserIDs = BlockedUserIDs(user)
|
||||
wheres = []string{}
|
||||
placeholders = []interface{}{}
|
||||
query = DB.Model(&Friend{})
|
||||
)
|
||||
|
||||
if requests && sent && ignored {
|
||||
return nil, errors.New("requests and sent are mutually exclusive options, use one or neither")
|
||||
}
|
||||
|
||||
if requests {
|
||||
query = DB.Where(
|
||||
"target_user_id = ? AND approved = ? AND ignored IS NOT true",
|
||||
user.ID, false,
|
||||
)
|
||||
} else if sent {
|
||||
query = DB.Where(
|
||||
"source_user_id = ? AND approved = ? AND ignored IS NOT true",
|
||||
user.ID, false,
|
||||
)
|
||||
} else if ignored {
|
||||
query = DB.Where(
|
||||
"target_user_id = ? AND approved = ? AND ignored = ?",
|
||||
user.ID, false, true,
|
||||
)
|
||||
} else {
|
||||
query = DB.Where(
|
||||
"source_user_id = ? AND approved = ?",
|
||||
user.ID, true,
|
||||
)
|
||||
// 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)
|
||||
}
|
||||
|
||||
query = query.Order(pager.Sort)
|
||||
// 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 {
|
||||
wheres = append(wheres, "target_user_id = ? AND approved = ? AND ignored IS NOT true")
|
||||
placeholders = append(placeholders, user.ID, false)
|
||||
|
||||
// Don't show friend requests from currently banned/disabled users.
|
||||
wheres = append(wheres, bannedWhereRequest)
|
||||
} else if sent {
|
||||
wheres = append(wheres, "source_user_id = ? AND approved = ? AND ignored IS NOT true")
|
||||
placeholders = append(placeholders, user.ID, false)
|
||||
|
||||
// Don't show friends who are currently banned/disabled.
|
||||
wheres = append(wheres, bannedWhereFriend)
|
||||
} else if ignored {
|
||||
wheres = append(wheres, "target_user_id = ? AND approved = ? AND ignored = ?")
|
||||
placeholders = append(placeholders, user.ID, false, true)
|
||||
|
||||
// Don't show friend requests from currently banned/disabled users.
|
||||
wheres = append(wheres, bannedWhereRequest)
|
||||
} else {
|
||||
wheres = append(wheres, "source_user_id = ? AND approved = ?")
|
||||
placeholders = append(placeholders, user.ID, true)
|
||||
|
||||
// Don't show friends who are currently banned/disabled.
|
||||
wheres = append(wheres, bannedWhereFriend)
|
||||
}
|
||||
|
||||
query = query.Where(
|
||||
strings.Join(wheres, " AND "),
|
||||
placeholders...,
|
||||
).Order(pager.Sort)
|
||||
query.Model(&Friend{}).Count(&pager.Total)
|
||||
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
|
||||
if result.Error != nil {
|
||||
|
@ -446,6 +492,27 @@ func RemoveFriend(sourceUserID, targetUserID uint64) 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.
|
||||
func (f *Friend) Save() error {
|
||||
result := DB.Save(f)
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user