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
|
/nonshy
|
||||||
/web/static/photos
|
/web/static/photos
|
||||||
|
/coldstorage
|
||||||
database.sqlite
|
database.sqlite
|
||||||
settings.json
|
settings.json
|
||||||
pgdump.sql
|
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
|
for local development. The production server runs on PostgreSQL and the
|
||||||
web app is primarily designed for that.
|
web app is primarily designed for that.
|
||||||
|
|
||||||
### PostGIS Extension for PostgreSQL
|
|
||||||
|
|
||||||
For the "Who's Nearby" feature to work you will need a PostgreSQL
|
|
||||||
database with the PostGIS geospatial extension installed. Usually
|
|
||||||
it might be a matter of `dnf install postgis` and activating the
|
|
||||||
extension on your nonshy database as your superuser (postgres):
|
|
||||||
|
|
||||||
```psql
|
|
||||||
create extension postgis;
|
|
||||||
```
|
|
||||||
|
|
||||||
If you get errors like "Type geography not found" from Postgres when
|
|
||||||
running distance based searches, this is the likely culprit.
|
|
||||||
|
|
||||||
## Building the App
|
## Building the App
|
||||||
|
|
||||||
This app is written in Go: [go.dev](https://go.dev). You can probably
|
This app is written in Go: [go.dev](https://go.dev). You can probably
|
||||||
|
@ -61,6 +47,96 @@ a database.
|
||||||
For simple local development, just set `"UseSQLite": true` and the
|
For simple local development, just set `"UseSQLite": true` and the
|
||||||
app will run with a SQLite database.
|
app will run with a SQLite database.
|
||||||
|
|
||||||
|
### Postgres is Highly Recommended
|
||||||
|
|
||||||
|
This website is intended to run under PostgreSQL and some of its
|
||||||
|
features leverage Postgres specific extensions. For quick local
|
||||||
|
development, SQLite will work fine but some website features will
|
||||||
|
be disabled and error messages given. These include:
|
||||||
|
|
||||||
|
* Location features such as "Who's Nearby" (PostGIS extension)
|
||||||
|
* "Newest" tab on the forums: to deduplicate comments by most recent
|
||||||
|
thread depends on Postgres, SQLite will always show all latest
|
||||||
|
comments without deduplication.
|
||||||
|
|
||||||
|
### PostGIS Extension for PostgreSQL
|
||||||
|
|
||||||
|
For the "Who's Nearby" feature to work you will need a PostgreSQL
|
||||||
|
database with the PostGIS geospatial extension installed. Usually
|
||||||
|
it might be a matter of `dnf install postgis` and activating the
|
||||||
|
extension on your nonshy database as your superuser (postgres):
|
||||||
|
|
||||||
|
```psql
|
||||||
|
create extension postgis;
|
||||||
|
```
|
||||||
|
|
||||||
|
If you get errors like "Type geography not found" from Postgres when
|
||||||
|
running distance based searches, this is the likely culprit.
|
||||||
|
|
||||||
|
### Signed Photo URLs (NGINX)
|
||||||
|
|
||||||
|
The website supports "signed photo" URLs that can help protect the direct
|
||||||
|
links to user photos (their /static/photos/*.jpg paths) to ensure only
|
||||||
|
logged-in and authorized users are able to access those links.
|
||||||
|
|
||||||
|
This feature is not enabled (enforcing) by default, as it relies on
|
||||||
|
cooperation with the NGINX reverse proxy server
|
||||||
|
(module ngx_http_auth_request).
|
||||||
|
|
||||||
|
In your NGINX config, set your /static/ path to leverage NGINX auth_request
|
||||||
|
like so:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
# your boilerplate server info (SSL, etc.) - not relevant to this example.
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
|
||||||
|
# Relevant: setting the /static/ URL on NGINX to be an alias to your local
|
||||||
|
# nonshy static folder on disk. In this example, the git clone for the
|
||||||
|
# website was at /home/www-user/git/nonshy/website, so that ./web/static/
|
||||||
|
# is the local path where static files (e.g., photos) are uploaded.
|
||||||
|
location /static/ {
|
||||||
|
# Important: auth_request tells NGINX to do subrequest authentication
|
||||||
|
# on requests into the /static/ URI of your website.
|
||||||
|
auth_request /static-auth;
|
||||||
|
|
||||||
|
# standard NGINX alias commands.
|
||||||
|
alias /home/www-user/git/nonshy/website/web/static/;
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Configure the internal subrequest auth path.
|
||||||
|
# Note: the path "/static-auth" can be anything you want.
|
||||||
|
location = /static-auth {
|
||||||
|
internal; # this is an internal route for NGINX only, not public
|
||||||
|
|
||||||
|
# Proxy to the /v1/auth/static URL on the web app.
|
||||||
|
# This line assumes the website runs on localhost:8080.
|
||||||
|
proxy_pass http://localhost:8080/v1/auth/static;
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
|
||||||
|
# Important: the X-Original-URI header tells the web app what the
|
||||||
|
# original path (e.g. /static/photos/*) was, so the web app knows
|
||||||
|
# which sub-URL to enforce authentication on.
|
||||||
|
proxy_set_header X-Original-URI $request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When your NGINX config is set up like the above, you can edit the
|
||||||
|
settings.json to mark SignedPhoto/Enabled=true, and restart the
|
||||||
|
website. Be sure to test it!
|
||||||
|
|
||||||
|
On a photo gallery view, all image URLs under /static/photos/ should
|
||||||
|
come with a ?jwt= parameter, and the image should load for the current
|
||||||
|
user. The JWT token is valid for 30 seconds after which the direct link
|
||||||
|
to the image should expire and give a 403 Forbidden response.
|
||||||
|
|
||||||
|
When this feature is NOT enabled/not enforcing: the jwt= parameter is
|
||||||
|
still generated on photo URLs but is not enforced by the web app.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
The `nonshy` binary has sub-commands to either run the web server
|
The `nonshy` binary has sub-commands to either run the web server
|
||||||
|
@ -126,26 +202,15 @@ the web app by using the admin controls on their profile page.
|
||||||
templates, issue redirects, error pages, ...
|
templates, issue redirects, error pages, ...
|
||||||
* `pkg/utility`: miscellaneous useful functions for the app.
|
* `pkg/utility`: miscellaneous useful functions for the app.
|
||||||
|
|
||||||
## Cron API Endpoints
|
## Cron workers
|
||||||
|
|
||||||
In settings.json get or configure the CronAPIKey (a UUID4 value is good and
|
You can schedule the `nonshy vacuum` command in your crontab. This command
|
||||||
the app generates a fresh one by default). The following are the cron API
|
will check and clean up the database for things such as: orphaned comment
|
||||||
endpoints that you may want to configure to run periodic maintenance tasks
|
photos (where somebody uploaded a photo to post on the forum, but then didn't
|
||||||
on the app, such as to remove orphaned comment photos.
|
finish creating their post).
|
||||||
|
|
||||||
### GET /v1/comment-photos/remove-orphaned
|
|
||||||
|
|
||||||
Query parameters: `apiKey` which is the CronAPIKey.
|
|
||||||
|
|
||||||
This endpoint removes orphaned CommentPhotos (photo attachments to forum
|
|
||||||
posts). An orphaned photo is one that has no CommentID and was uploaded
|
|
||||||
older than 24 hours ago; e.g. a user uploaded a picture but then did not
|
|
||||||
complete the posting of their comment.
|
|
||||||
|
|
||||||
Suggested crontab:
|
|
||||||
|
|
||||||
```cron
|
```cron
|
||||||
0 2 * * * curl "http://localhost:8080/v1/comment-photos/remove-orphaned?apiKey=X"
|
0 2 * * * cd /home/nonshy/git/website && ./nonshy vacuum
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
|
|
||||||
nonshy "code.nonshy.com/nonshy/website/pkg"
|
nonshy "code.nonshy.com/nonshy/website/pkg"
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/encryption/coldstorage"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models/backfill"
|
"code.nonshy.com/nonshy/website/pkg/models/backfill"
|
||||||
|
@ -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",
|
Name: "backfill",
|
||||||
Usage: "One-off maintenance tasks and data backfills for database migrations",
|
Usage: "One-off maintenance tasks and data backfills for database migrations",
|
||||||
|
@ -188,6 +264,35 @@ func main() {
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "photo-counts",
|
||||||
|
Usage: "repopulate cached Likes and Comment counts on photos",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
initdb(c)
|
||||||
|
|
||||||
|
log.Info("Running BackfillPhotoCounts()")
|
||||||
|
err := backfill.BackfillPhotoCounts()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "vacuum",
|
||||||
|
Usage: "Run database maintenance tasks (clean up broken links, remove orphaned comment photos, etc.) for data consistency.",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "dryrun",
|
||||||
|
Usage: "don't actually delete anything",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
initdb(c)
|
||||||
|
return worker.Vacuum(c.Bool("dryrun"))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
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
|
module code.nonshy.com/nonshy/website
|
||||||
|
|
||||||
go 1.18
|
go 1.22
|
||||||
|
|
||||||
|
toolchain go1.22.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b
|
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b
|
||||||
|
github.com/SherClockHolmes/webpush-go v1.3.0
|
||||||
|
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f
|
||||||
github.com/go-redis/redis/v8 v8.11.5
|
github.com/go-redis/redis/v8 v8.11.5
|
||||||
github.com/google/uuid v1.3.0
|
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||||
github.com/urfave/cli/v2 v2.11.1
|
github.com/google/uuid v1.6.0
|
||||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
|
github.com/microcosm-cc/bluemonday v1.0.26
|
||||||
gorm.io/driver/postgres v1.3.8
|
github.com/oschwald/geoip2-golang v1.9.0
|
||||||
gorm.io/driver/sqlite v1.3.6
|
github.com/pquerna/otp v1.4.0
|
||||||
gorm.io/gorm v1.23.8
|
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 (
|
require (
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
github.com/boombuler/barcode v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/disintegration/imaging v1.6.2 // indirect
|
github.com/disintegration/imaging v1.6.2 // indirect
|
||||||
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f // indirect
|
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||||
github.com/go-redis/redis v6.15.9+incompatible // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/golang-jwt/jwt/v4 v4.4.3 // indirect
|
|
||||||
github.com/gorilla/css v1.0.0 // indirect
|
|
||||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
|
||||||
github.com/jackc/pgconn v1.12.1 // indirect
|
|
||||||
github.com/jackc/pgio v1.0.0 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgproto3/v2 v2.3.0 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
|
github.com/jackc/pgx/v5 v5.5.3 // indirect
|
||||||
github.com/jackc/pgtype v1.11.0 // indirect
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
github.com/jackc/pgx/v4 v4.16.1 // indirect
|
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.14 // indirect
|
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
github.com/microcosm-cc/bluemonday v1.0.19 // indirect
|
github.com/oschwald/maxminddb-golang v1.12.0 // indirect
|
||||||
github.com/oschwald/geoip2-golang v1.9.0 // indirect
|
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||||
github.com/oschwald/maxminddb-golang v1.11.0 // indirect
|
github.com/russross/blackfriday v1.6.0 // indirect
|
||||||
github.com/pquerna/otp v1.4.0 // indirect
|
|
||||||
github.com/russross/blackfriday v1.5.2 // indirect
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||||
github.com/sergi/go-diff v1.2.0 // indirect
|
github.com/sergi/go-diff v1.3.1 // indirect
|
||||||
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 // indirect
|
github.com/shurcooL/go v0.0.0-20230706063926-5fe729b41b3a // indirect
|
||||||
github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480 // indirect
|
github.com/shurcooL/go-goon v1.0.0 // indirect
|
||||||
github.com/shurcooL/highlight_go v0.0.0-20191220051317-782971ddf21b // indirect
|
github.com/shurcooL/highlight_diff v0.0.0-20230708024848-22f825814995 // indirect
|
||||||
github.com/shurcooL/octicon v0.0.0-20191102190552-cbb32d6a785c // 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/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||||
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d // indirect
|
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d // indirect
|
||||||
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect
|
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect
|
||||||
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect
|
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect
|
||||||
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 // indirect
|
golang.org/x/net v0.21.0 // indirect
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect
|
golang.org/x/sync v0.6.0 // indirect
|
||||||
golang.org/x/sys v0.9.0 // indirect
|
golang.org/x/sys v0.17.0 // indirect
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
|
golang.org/x/term v0.17.0 // indirect
|
||||||
golang.org/x/text v0.3.7 // indirect
|
|
||||||
golang.org/x/tools v0.1.12 // indirect
|
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect
|
|
||||||
)
|
)
|
||||||
|
|
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 h1:TDxEEWOJqMzsu9JW8/QgmT1lgQ9WD2KWlb2lKN/Ql2o=
|
||||||
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b/go.mod h1:jl+Qr58W3Op7OCxIYIT+b42jq8xFncJXzPufhrvza7Y=
|
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b/go.mod h1:jl+Qr58W3Op7OCxIYIT+b42jq8xFncJXzPufhrvza7Y=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/SherClockHolmes/webpush-go v1.3.0 h1:CAu3FvEE9QS4drc3iKNgpBWFfGqNthKlZhp5QpYnu6k=
|
||||||
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
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 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
|
||||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
|
||||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
@ -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/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f h1:RMnUwTnNR070mFAEIoqMYjNirHj8i0h79VXTYyBCyVA=
|
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f h1:RMnUwTnNR070mFAEIoqMYjNirHj8i0h79VXTYyBCyVA=
|
||||||
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc=
|
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc=
|
||||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
|
|
||||||
github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
|
||||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||||
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||||
github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
|
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||||
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
|
||||||
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
|
|
||||||
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
|
|
||||||
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
|
||||||
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
|
||||||
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
|
||||||
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
|
|
||||||
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
|
|
||||||
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
|
|
||||||
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
|
|
||||||
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
|
|
||||||
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
|
|
||||||
github.com/jackc/pgconn v1.12.1 h1:rsDFzIpRk7xT4B8FufgpCCeyjdNpKyghZeSefViE5W8=
|
|
||||||
github.com/jackc/pgconn v1.12.1/go.mod h1:ZkhRC59Llhrq3oSfrikvwQ5NaxYExr6twkdkMLaKono=
|
|
||||||
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
|
||||||
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
|
||||||
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
|
|
||||||
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
|
|
||||||
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
|
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
|
||||||
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
|
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
|
github.com/jackc/pgx/v5 v5.5.3 h1:Ces6/M3wbDXYpM8JyyPD57ivTtJACFZJd885pdIaV2s=
|
||||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
|
github.com/jackc/pgx/v5 v5.5.3/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
|
||||||
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
|
||||||
github.com/jackc/pgproto3/v2 v2.3.0 h1:brH0pCGBDkBW07HWlN/oSBXrmo3WB0UvZd1pIuDcL8Y=
|
|
||||||
github.com/jackc/pgproto3/v2 v2.3.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
|
||||||
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
|
|
||||||
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
|
|
||||||
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
|
|
||||||
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
|
|
||||||
github.com/jackc/pgtype v1.11.0 h1:u4uiGPz/1hryuXzyaBhSk6dnIyyG2683olG2OV+UUgs=
|
|
||||||
github.com/jackc/pgtype v1.11.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
|
|
||||||
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
|
|
||||||
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
|
|
||||||
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
|
|
||||||
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
|
|
||||||
github.com/jackc/pgx/v4 v4.16.1 h1:JzTglcal01DrghUqt+PmzWsZx/Yh7SC/CTQmSBMTd0Y=
|
|
||||||
github.com/jackc/pgx/v4 v4.16.1/go.mod h1:SIhx0D5hoADaiXZVyv+3gSm3LCIIINTVO0PficsvWGQ=
|
|
||||||
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
|
||||||
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
|
||||||
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
|
||||||
github.com/jackc/puddle v1.2.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/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/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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
||||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
|
||||||
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||||
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
|
||||||
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
|
||||||
github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
|
||||||
github.com/microcosm-cc/bluemonday v1.0.19 h1:OI7hoF5FY4pFz2VA//RN8TfM0YJ2dJcl4P4APrCWy6c=
|
|
||||||
github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE=
|
|
||||||
github.com/oschwald/geoip2-golang v1.9.0 h1:uvD3O6fXAXs+usU+UGExshpdP13GAqp4GBrzN7IgKZc=
|
github.com/oschwald/geoip2-golang v1.9.0 h1:uvD3O6fXAXs+usU+UGExshpdP13GAqp4GBrzN7IgKZc=
|
||||||
github.com/oschwald/geoip2-golang v1.9.0/go.mod h1:BHK6TvDyATVQhKNbQBdrj9eAvuwOMi2zSFXizL3K81Y=
|
github.com/oschwald/geoip2-golang v1.9.0/go.mod h1:BHK6TvDyATVQhKNbQBdrj9eAvuwOMi2zSFXizL3K81Y=
|
||||||
github.com/oschwald/maxminddb-golang v1.11.0 h1:aSXMqYR/EPNjGE8epgqwDay+P30hCBZIveY0WZbAWh0=
|
github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs=
|
||||||
github.com/oschwald/maxminddb-golang v1.11.0/go.mod h1:YmVI+H0zh3ySFR3w+oz8PCfglAFj3PuCmui13+P9zDg=
|
github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||||
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
|
||||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
|
||||||
github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
|
|
||||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||||
|
github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww=
|
||||||
|
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
|
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||||
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
|
||||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
|
||||||
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
|
||||||
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 h1:86e54L0i3pH3dAIA8OxBbfLrVyhoGpnNk1iJCigAWYs=
|
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 h1:86e54L0i3pH3dAIA8OxBbfLrVyhoGpnNk1iJCigAWYs=
|
||||||
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0=
|
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0=
|
||||||
github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480 h1:KaKXZldeYH73dpQL+Nr38j1r5BgpAYQjYvENOUpIZDQ=
|
github.com/shurcooL/go v0.0.0-20230706063926-5fe729b41b3a h1:ZHfoO7ZJhws9NU1kzZhStUnnVQiPtDe1PzpUnc6HirU=
|
||||||
github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU=
|
github.com/shurcooL/go v0.0.0-20230706063926-5fe729b41b3a/go.mod h1:DNrlr0AR9NsHD/aoc2pPeu4uSBZ/71yCHkR42yrzW3M=
|
||||||
github.com/shurcooL/highlight_go v0.0.0-20191220051317-782971ddf21b h1:rBIwpb5ggtqf0uZZY5BPs1sL7njUMM7I8qD2jiou70E=
|
github.com/shurcooL/go-goon v1.0.0 h1:BCQPvxGkHHJ4WpBO4m/9FXbITVIsvAm/T66cCcCGI7E=
|
||||||
github.com/shurcooL/highlight_go v0.0.0-20191220051317-782971ddf21b/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag=
|
github.com/shurcooL/go-goon v1.0.0/go.mod h1:2wTHMsGo7qnpmqA8ADYZtP4I1DD94JpXGQ3Dxq2YQ5w=
|
||||||
github.com/shurcooL/octicon v0.0.0-20191102190552-cbb32d6a785c h1:p3w+lTqXulfa3aDeycxmcLJDNxyUB89gf2/XqqK3eO0=
|
github.com/shurcooL/highlight_diff v0.0.0-20230708024848-22f825814995 h1:/6Fa0HAouqks/nlr3C3sv7KNDqutP3CM/MYz225uO28=
|
||||||
github.com/shurcooL/octicon v0.0.0-20191102190552-cbb32d6a785c/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ=
|
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 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
|
||||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
|
||||||
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d h1:yKm7XZV6j9Ev6lojP2XaIshpT4ymkqhMeSghO5Ps00E=
|
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d h1:yKm7XZV6j9Ev6lojP2XaIshpT4ymkqhMeSghO5Ps00E=
|
||||||
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE=
|
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE=
|
||||||
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e h1:qpG93cPwA5f7s/ZPBJnGOYQNK/vKsaDaseuKT5Asee8=
|
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e h1:qpG93cPwA5f7s/ZPBJnGOYQNK/vKsaDaseuKT5Asee8=
|
||||||
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
|
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e h1:Ee+VZw13r9NTOMnwTPs6O5KZ0MJU54hsxu9FpZ4pQ10=
|
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e h1:Ee+VZw13r9NTOMnwTPs6O5KZ0MJU54hsxu9FpZ4pQ10=
|
||||||
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e/go.mod h1:fSIW/szJHsRts/4U8wlMPhs+YqJC+7NYR+Qqb1uJVpA=
|
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e/go.mod h1:fSIW/szJHsRts/4U8wlMPhs+YqJC+7NYR+Qqb1uJVpA=
|
||||||
github.com/urfave/cli/v2 v2.11.1 h1:UKK6SP7fV3eKOefbS87iT9YHefv7iB/53ih6e+GNAsE=
|
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
|
||||||
github.com/urfave/cli/v2 v2.11.1/go.mod h1:f8iq5LtQ/bLxafbdBSLPPNsgaW0l/2fYYEHhAyPlwvo=
|
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI=
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
|
||||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
|
||||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
|
||||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
|
||||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
|
||||||
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
|
||||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
|
||||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
|
||||||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
|
||||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
|
||||||
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
|
||||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
|
||||||
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
|
||||||
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
|
||||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c=
|
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
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-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.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
|
||||||
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
|
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=
|
|
||||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0=
|
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/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-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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
|
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s=
|
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
|
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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
|
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
|
||||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
|
||||||
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
|
||||||
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
|
||||||
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
|
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
|
||||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
||||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
||||||
gopkg.in/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.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=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/postgres v1.3.8 h1:8bEphSAB69t3odsCR4NDzt581iZEWQuRM27Cg6KgfPY=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gorm.io/driver/postgres v1.3.8/go.mod h1:qB98Aj6AhRO/oyu/jmZsi/YM9g6UzVCjMxO/6frFvcA=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/sqlite v1.3.6 h1:Fi8xNYCUplOqWiPa3/GuCeowRNBRGTf62DEmhMDHeQQ=
|
gorm.io/driver/postgres v1.5.6 h1:ydr9xEd5YAM0vxVDY0X139dyzNz10spDiDlC7+ibLeU=
|
||||||
gorm.io/driver/sqlite v1.3.6/go.mod h1:Sg1/pvnKtbQ7jLXxfZa+jSHvoX8hoZA8cn4xllOMTgE=
|
gorm.io/driver/postgres v1.5.6/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
|
||||||
gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
|
||||||
gorm.io/gorm v1.23.6/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
|
||||||
gorm.io/gorm v1.23.8 h1:h8sGJ+biDgBA1AD1Ha9gFCx7h8npU7AsLdlkX0n2TpE=
|
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
|
||||||
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
|
||||||
|
|
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
|
// - Chat: have operator controls in the chat room
|
||||||
// - Forum: ability to edit and delete user posts
|
// - Forum: ability to edit and delete user posts
|
||||||
// - Photo: omniscient view of all gallery photos, can edit/delete photos
|
// - Photo: omniscient view of all gallery photos, can edit/delete photos
|
||||||
// - Inner circle: ability to remove users from it
|
ScopeChatModerator = "social.moderator.chat"
|
||||||
ScopeChatModerator = "social.moderator.chat"
|
ScopeForumModerator = "social.moderator.forum"
|
||||||
ScopeForumModerator = "social.moderator.forum"
|
ScopePhotoModerator = "social.moderator.photo"
|
||||||
ScopePhotoModerator = "social.moderator.photo"
|
|
||||||
ScopeCircleModerator = "social.moderator.inner-circle"
|
|
||||||
|
|
||||||
// Certification photo management
|
// Certification photo management
|
||||||
// - Approve: ability to respond to pending certification pics
|
// - Approve: ability to respond to pending certification pics
|
||||||
|
@ -32,21 +30,53 @@ const (
|
||||||
// - Impersonate: ability to log in as a user account
|
// - Impersonate: ability to log in as a user account
|
||||||
// - Ban: ability to ban/unban users
|
// - Ban: ability to ban/unban users
|
||||||
// - Delete: ability to delete user accounts
|
// - Delete: ability to delete user accounts
|
||||||
|
ScopeUserCreate = "admin.user.create"
|
||||||
ScopeUserInsight = "admin.user.insights"
|
ScopeUserInsight = "admin.user.insights"
|
||||||
ScopeUserImpersonate = "admin.user.impersonate"
|
ScopeUserImpersonate = "admin.user.impersonate"
|
||||||
ScopeUserBan = "admin.user.ban"
|
ScopeUserBan = "admin.user.ban"
|
||||||
ScopeUserPromote = "admin.user.promote"
|
ScopeUserPassword = "admin.user.password"
|
||||||
ScopeUserDelete = "admin.user.delete"
|
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.
|
// Admins with this scope can not be blocked by users.
|
||||||
ScopeUnblockable = "admin.unblockable"
|
ScopeUnblockable = "admin.unblockable"
|
||||||
|
|
||||||
// Special scope to mark an admin automagically in the Inner Circle
|
// The global wildcard scope gets all available permissions.
|
||||||
ScopeIsInnerCircle = "admin.override.inner-circle"
|
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.
|
// Number of expected scopes for unit test and validation.
|
||||||
const QuantityAdminScopes = 16
|
const QuantityAdminScopes = 20
|
||||||
|
|
||||||
// The specially named Superusers group.
|
// The specially named Superusers group.
|
||||||
const AdminGroupSuperusers = "Superusers"
|
const AdminGroupSuperusers = "Superusers"
|
||||||
|
@ -57,18 +87,26 @@ func ListAdminScopes() []string {
|
||||||
ScopeChatModerator,
|
ScopeChatModerator,
|
||||||
ScopeForumModerator,
|
ScopeForumModerator,
|
||||||
ScopePhotoModerator,
|
ScopePhotoModerator,
|
||||||
ScopeCircleModerator,
|
|
||||||
ScopeCertificationApprove,
|
ScopeCertificationApprove,
|
||||||
ScopeCertificationList,
|
ScopeCertificationList,
|
||||||
ScopeCertificationView,
|
ScopeCertificationView,
|
||||||
ScopeForumAdmin,
|
ScopeForumAdmin,
|
||||||
ScopeAdminScopeAdmin,
|
ScopeAdminScopeAdmin,
|
||||||
|
ScopeMaintenance,
|
||||||
|
ScopeUserCreate,
|
||||||
ScopeUserInsight,
|
ScopeUserInsight,
|
||||||
ScopeUserImpersonate,
|
ScopeUserImpersonate,
|
||||||
ScopeUserBan,
|
ScopeUserBan,
|
||||||
|
ScopeUserPassword,
|
||||||
ScopeUserDelete,
|
ScopeUserDelete,
|
||||||
ScopeUserPromote,
|
ScopeUserPromote,
|
||||||
|
ScopeFeedbackAndReports,
|
||||||
|
ScopeChangeLog,
|
||||||
|
ScopeUserNotes,
|
||||||
ScopeUnblockable,
|
ScopeUnblockable,
|
||||||
ScopeIsInnerCircle,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AdminScopeDescription(scope string) string {
|
||||||
|
return AdminScopeDescriptions[scope]
|
||||||
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import (
|
||||||
// returned by the scope list function.
|
// returned by the scope list function.
|
||||||
func TestAdminScopesCount(t *testing.T) {
|
func TestAdminScopesCount(t *testing.T) {
|
||||||
var scopes = config.ListAdminScopes()
|
var scopes = config.ListAdminScopes()
|
||||||
if len(scopes) != config.QuantityAdminScopes {
|
if len(scopes) != config.QuantityAdminScopes || len(scopes) != len(config.AdminScopeDescriptions) {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"The list of scopes returned by ListAdminScopes doesn't match the expected count. "+
|
"The list of scopes returned by ListAdminScopes doesn't match the expected count. "+
|
||||||
"Expected %d, got %d",
|
"Expected %d, got %d",
|
||||||
|
|
|
@ -25,6 +25,10 @@ const (
|
||||||
PhotoDiskPath = "./web/static/photos"
|
PhotoDiskPath = "./web/static/photos"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PhotoURLRegexp describes an image path under "/static/photos" that can be parsed from Markdown or HTML input.
|
||||||
|
// It is used by e.g. the ReSignURLs function - if you move image URLs to a CDN this may need updating.
|
||||||
|
var PhotoURLRegexp = regexp.MustCompile(`(?:['"])(/static/photos/[^'"\s?]+(?:\?[^'"\s]*)?)(?:['"]|[^'"\s]*)`)
|
||||||
|
|
||||||
// Security
|
// Security
|
||||||
const (
|
const (
|
||||||
BcryptCost = 14
|
BcryptCost = 14
|
||||||
|
@ -38,6 +42,11 @@ const (
|
||||||
|
|
||||||
TwoFactorBackupCodeCount = 12
|
TwoFactorBackupCodeCount = 12
|
||||||
TwoFactorBackupCodeLength = 8 // characters a-z0-9
|
TwoFactorBackupCodeLength = 8 // characters a-z0-9
|
||||||
|
|
||||||
|
// Signed URLs for static photo authentication.
|
||||||
|
SignedPhotoJWTExpires = 30 * time.Second // Regular, per-user, short window
|
||||||
|
SignedPublicAvatarJWTExpires = 7 * 24 * time.Hour // Widely public, e.g. chat room
|
||||||
|
SignedPublicAvatarUsername = "@" // JWT 'username' for widely public JWT
|
||||||
)
|
)
|
||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
|
@ -51,6 +60,11 @@ const (
|
||||||
ChangeEmailRedisKey = "change-email/%s"
|
ChangeEmailRedisKey = "change-email/%s"
|
||||||
SignupTokenExpires = 24 * time.Hour // used for all tokens so far
|
SignupTokenExpires = 24 * time.Hour // used for all tokens so far
|
||||||
|
|
||||||
|
// How to rate limit same types of emails being delivered, e.g.
|
||||||
|
// signups, cert approvals (double post), etc.
|
||||||
|
EmailDebounceDefault = 24 * time.Hour // default debounce per type of email
|
||||||
|
EmailDebounceResetPassword = 4 * time.Hour // "forgot password" emails debounce
|
||||||
|
|
||||||
// Rate limits
|
// Rate limits
|
||||||
RateLimitRedisKey = "rate-limit/%s/%s" // namespace, id
|
RateLimitRedisKey = "rate-limit/%s/%s" // namespace, id
|
||||||
LoginRateLimitWindow = 1 * time.Hour
|
LoginRateLimitWindow = 1 * time.Hour
|
||||||
|
@ -66,15 +80,25 @@ const (
|
||||||
ContactRateLimitCooldownAt = 1
|
ContactRateLimitCooldownAt = 1
|
||||||
ContactRateLimitCooldown = 2 * time.Minute
|
ContactRateLimitCooldown = 2 * time.Minute
|
||||||
|
|
||||||
|
// "Mark Explicit" rate limit to curb a mischievous user just bulk marking the
|
||||||
|
// whole gallery as explicit.
|
||||||
|
MarkExplicitRateLimitWindow = 1 * time.Hour
|
||||||
|
MarkExplicitRateLimit = 20 // 10 failed MarkExplicit attempts = locked for full hour
|
||||||
|
MarkExplicitRateLimitCooldownAt = 10 // 10 photos in an hour, start throttling.
|
||||||
|
MarkExplicitRateLimitCooldown = time.Minute
|
||||||
|
|
||||||
// How frequently to refresh LastLoginAt since sessions are long-lived.
|
// How frequently to refresh LastLoginAt since sessions are long-lived.
|
||||||
LastLoginAtCooldown = time.Hour
|
LastLoginAtCooldown = time.Hour
|
||||||
|
|
||||||
// Chat room status refresh interval.
|
// Chat room status refresh interval.
|
||||||
ChatStatusRefreshInterval = 30 * time.Second
|
ChatStatusRefreshInterval = 30 * time.Second
|
||||||
|
|
||||||
|
// Cache TTL for the demographics page.
|
||||||
|
DemographicsCacheTTL = time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
UsernameRegexp = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
|
UsernameRegexp = regexp.MustCompile(`^[a-z0-9_.-]{3,32}$`)
|
||||||
ReservedUsernames = []string{
|
ReservedUsernames = []string{
|
||||||
"admin",
|
"admin",
|
||||||
"admins",
|
"admins",
|
||||||
|
@ -94,20 +118,24 @@ var (
|
||||||
const (
|
const (
|
||||||
MaxPhotoWidth = 1280
|
MaxPhotoWidth = 1280
|
||||||
ProfilePhotoWidth = 512
|
ProfilePhotoWidth = 512
|
||||||
|
AltTextMaxLength = 5000
|
||||||
|
|
||||||
// Quotas for uploaded photos.
|
// Quotas for uploaded photos.
|
||||||
PhotoQuotaUncertified = 6
|
PhotoQuotaUncertified = 6
|
||||||
PhotoQuotaCertified = 100
|
PhotoQuotaCertified = 100
|
||||||
|
|
||||||
// Min number of public photos for inner circle members to see the prompt to invite.
|
|
||||||
InnerCircleMinimumPublicPhotos = 5
|
|
||||||
|
|
||||||
// Rate limit for too many Site Gallery pictures.
|
// Rate limit for too many Site Gallery pictures.
|
||||||
// Some users sign up and immediately max out their gallery and spam
|
// Some users sign up and immediately max out their gallery and spam
|
||||||
// the Site Gallery page. These limits can ensure only a few Site Gallery
|
// the Site Gallery page. These limits can ensure only a few Site Gallery
|
||||||
// pictures can be posted per day.
|
// pictures can be posted per day.
|
||||||
SiteGalleryRateLimitMax = 5
|
SiteGalleryRateLimitMax = 5
|
||||||
SiteGalleryRateLimitInterval = 24 * time.Hour
|
SiteGalleryRateLimitInterval = 24 * time.Hour
|
||||||
|
|
||||||
|
// Only ++ the Views count per user per photo within a small
|
||||||
|
// window of time - if a user keeps reloading the same photo
|
||||||
|
// rapidly it does not increment the view counter more.
|
||||||
|
PhotoViewDebounceRedisKey = "debounce-view/user=%d/photoid=%d"
|
||||||
|
PhotoViewDebounceCooldown = 1 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// Forum settings
|
// Forum settings
|
||||||
|
@ -117,6 +145,27 @@ const (
|
||||||
// rapidly it does not increment the view counter more.
|
// rapidly it does not increment the view counter more.
|
||||||
ThreadViewDebounceRedisKey = "debounce-view/user=%d/thr=%d"
|
ThreadViewDebounceRedisKey = "debounce-view/user=%d/thr=%d"
|
||||||
ThreadViewDebounceCooldown = 1 * time.Hour
|
ThreadViewDebounceCooldown = 1 * time.Hour
|
||||||
|
|
||||||
|
// Enable user-owned forums (feature flag)
|
||||||
|
UserForumsEnabled = true
|
||||||
|
)
|
||||||
|
|
||||||
|
// User-Owned Forums: Quota settings for how many forums a user can own.
|
||||||
|
var (
|
||||||
|
// They get one forum after they've been Certified for 45 days.
|
||||||
|
UserForumQuotaCertLifetimeDays = time.Hour * 24 * 45
|
||||||
|
|
||||||
|
// Schedule for gaining additional quota for a number of comments written
|
||||||
|
// on any forum thread. The user must have the sum of all of these post
|
||||||
|
// counts to gain one forum per level.
|
||||||
|
UserForumQuotaCommentCountSchedule = []int64{
|
||||||
|
10, // Get a forum after your first 10 posts.
|
||||||
|
20, // Get a 2nd forum after 20 additional posts (30 total)
|
||||||
|
30, // 30 more posts (60 total)
|
||||||
|
60, // 60 more posts (120 total)
|
||||||
|
80, // 80 more posts (200 total)
|
||||||
|
100, // and then one new forum for every 100 additional posts
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Poll settings
|
// Poll settings
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
|
import "regexp"
|
||||||
|
|
||||||
// Various hard-coded enums such as choice of gender, sexuality, relationship status etc.
|
// Various hard-coded enums such as choice of gender, sexuality, relationship status etc.
|
||||||
var (
|
var (
|
||||||
MaritalStatus = []string{
|
MaritalStatus = []string{
|
||||||
|
@ -32,6 +34,8 @@ var (
|
||||||
"Gay",
|
"Gay",
|
||||||
"Bisexual",
|
"Bisexual",
|
||||||
"Bicurious",
|
"Bicurious",
|
||||||
|
"Pansexual",
|
||||||
|
"Asexual",
|
||||||
}
|
}
|
||||||
|
|
||||||
HereFor = []string{
|
HereFor = []string{
|
||||||
|
@ -64,12 +68,18 @@ var (
|
||||||
"music_movies",
|
"music_movies",
|
||||||
"hide_age",
|
"hide_age",
|
||||||
}
|
}
|
||||||
|
EssayProfileFields = []string{
|
||||||
|
"about_me",
|
||||||
|
"interests",
|
||||||
|
"music_movies",
|
||||||
|
}
|
||||||
|
|
||||||
// Site preference names (stored in ProfileField table)
|
// Site preference names (stored in ProfileField table)
|
||||||
SitePreferenceFields = []string{
|
SitePreferenceFields = []string{
|
||||||
"dm_privacy",
|
"dm_privacy",
|
||||||
"blur_explicit",
|
"blur_explicit",
|
||||||
"site_gallery_default", // default view on site gallery (friends-only or all certified?)
|
"site_gallery_default", // default view on site gallery (friends-only or all certified?)
|
||||||
|
"chat_moderation_rules",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Choices for the Contact Us subject
|
// Choices for the Contact Us subject
|
||||||
|
@ -90,6 +100,8 @@ var (
|
||||||
{"report.photo", "Report a problematic photo"},
|
{"report.photo", "Report a problematic photo"},
|
||||||
{"report.message", "Report a direct message conversation"},
|
{"report.message", "Report a direct message conversation"},
|
||||||
{"report.comment", "Report a forum post or comment"},
|
{"report.comment", "Report a forum post or comment"},
|
||||||
|
{"report.forum", "Report a forum or community"},
|
||||||
|
{"forum.adopt", "Adopt a forum"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -97,12 +109,41 @@ var (
|
||||||
// Default forum categories for forum landing page.
|
// Default forum categories for forum landing page.
|
||||||
ForumCategories = []string{
|
ForumCategories = []string{
|
||||||
"Rules and Announcements",
|
"Rules and Announcements",
|
||||||
"The Inner Circle",
|
|
||||||
"Nudists",
|
"Nudists",
|
||||||
"Exhibitionists",
|
"Exhibitionists",
|
||||||
"Photo Boards",
|
"Photo Boards",
|
||||||
"Anything Goes",
|
"Anything Goes",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keywords that appear in a DM that make it likely spam.
|
||||||
|
DirectMessageSpamKeywords = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`\b(telegram|whats\s*app|signal|kik|session)\b`),
|
||||||
|
regexp.MustCompile(`https?://(t.me|join.skype.com|zoom.us|whereby.com|meet.jit.si|wa.me)`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat Moderation Rules.
|
||||||
|
ChatModerationRules = []ChecklistOption{
|
||||||
|
{
|
||||||
|
Value: "redcam",
|
||||||
|
Label: "Red camera",
|
||||||
|
Help: "The user's camera is forced to 'explicit' when they are broadcasting.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "nobroadcast",
|
||||||
|
Label: "No broadcast",
|
||||||
|
Help: "The user can not broadcast their webcam, but may still watch other peoples' webcams.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "novideo",
|
||||||
|
Label: "No webcam privileges ('Shy Accounts')",
|
||||||
|
Help: "The user can not broadcast or watch any webcam. Note: this option supercedes all other video-related rules.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "noimage",
|
||||||
|
Label: "No image sharing privileges ('Shy Accounts')",
|
||||||
|
Help: "The user can not share or see any image shared on chat.",
|
||||||
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// ContactUs choices for the subject drop-down.
|
// ContactUs choices for the subject drop-down.
|
||||||
|
@ -117,6 +158,13 @@ type Option struct {
|
||||||
Label string
|
Label string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChecklistOption for checkbox-lists.
|
||||||
|
type ChecklistOption struct {
|
||||||
|
Value string
|
||||||
|
Label string
|
||||||
|
Help string
|
||||||
|
}
|
||||||
|
|
||||||
// NotificationOptout field values (stored in user ProfileField table)
|
// NotificationOptout field values (stored in user ProfileField table)
|
||||||
const (
|
const (
|
||||||
NotificationOptOutFriendPhotos = "notif_optout_friends_photos"
|
NotificationOptOutFriendPhotos = "notif_optout_friends_photos"
|
||||||
|
@ -127,6 +175,10 @@ const (
|
||||||
NotificationOptOutSubscriptions = "notif_optout_subscriptions"
|
NotificationOptOutSubscriptions = "notif_optout_subscriptions"
|
||||||
NotificationOptOutFriendRequestAccepted = "notif_optout_friend_request_accepted"
|
NotificationOptOutFriendRequestAccepted = "notif_optout_friend_request_accepted"
|
||||||
NotificationOptOutPrivateGrant = "notif_optout_private_grant"
|
NotificationOptOutPrivateGrant = "notif_optout_private_grant"
|
||||||
|
|
||||||
|
// Web Push Notifications
|
||||||
|
PushNotificationOptOutMessage = "notif_optout_push_messages"
|
||||||
|
PushNotificationOptOutFriends = "notif_optout_push_friends"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Notification opt-outs (stored in ProfileField table)
|
// Notification opt-outs (stored in ProfileField table)
|
||||||
|
@ -140,3 +192,9 @@ var NotificationOptOutFields = []string{
|
||||||
NotificationOptOutFriendRequestAccepted,
|
NotificationOptOutFriendRequestAccepted,
|
||||||
NotificationOptOutPrivateGrant,
|
NotificationOptOutPrivateGrant,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push Notification opt-outs (stored in ProfileField table)
|
||||||
|
var PushNotificationOptOutFields = []string{
|
||||||
|
PushNotificationOptOutMessage,
|
||||||
|
PushNotificationOptOutFriends,
|
||||||
|
}
|
||||||
|
|
|
@ -15,13 +15,16 @@ var (
|
||||||
PageSizePrivatePhotoGrantees = 12
|
PageSizePrivatePhotoGrantees = 12
|
||||||
PageSizeAdminCertification = 20
|
PageSizeAdminCertification = 20
|
||||||
PageSizeAdminFeedback = 20
|
PageSizeAdminFeedback = 20
|
||||||
PageSizeAdminFeedbackNotesPage = 5 // feedback on User Notes page
|
PageSizeAdminFeedbackNotesPage = 5 // feedback on User Notes page
|
||||||
|
PageSizeChangeLog = 20
|
||||||
PageSizeAdminUserNotes = 10 // other users' notes
|
PageSizeAdminUserNotes = 10 // other users' notes
|
||||||
PageSizeSiteGallery = 16
|
PageSizeSiteGallery = 16
|
||||||
PageSizeUserGallery = 16
|
PageSizeUserGallery = 16
|
||||||
PageSizeInboxList = 20 // sidebar list
|
PageSizeInboxList = 20 // sidebar list
|
||||||
PageSizeInboxThread = 10 // conversation view
|
PageSizeInboxThread = 10 // conversation view
|
||||||
|
PageSizeBrowseForums = 20
|
||||||
PageSizeForums = 100 // TODO: for main category index view
|
PageSizeForums = 100 // TODO: for main category index view
|
||||||
|
PageSizeMyListForums = 20 // "My List" pager on forum home (categories) page.
|
||||||
PageSizeThreadList = 20 // 20 threads per board, 20 posts per thread
|
PageSizeThreadList = 20 // 20 threads per board, 20 posts per thread
|
||||||
PageSizeForumAdmin = 20
|
PageSizeForumAdmin = 20
|
||||||
PageSizeDashboardNotifications = 50
|
PageSizeDashboardNotifications = 50
|
||||||
|
|
|
@ -4,17 +4,18 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/encryption/coldstorage"
|
||||||
"code.nonshy.com/nonshy/website/pkg/encryption/keygen"
|
"code.nonshy.com/nonshy/website/pkg/encryption/keygen"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
"github.com/SherClockHolmes/webpush-go"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version of the config format - when new fields are added, it will attempt
|
// Version of the config format - when new fields are added, it will attempt
|
||||||
// to write the settings.toml to disk so new defaults populate.
|
// to write the settings.toml to disk so new defaults populate.
|
||||||
var currentVersion = 2
|
var currentVersion = 5
|
||||||
|
|
||||||
// Current loaded settings.json
|
// Current loaded settings.json
|
||||||
var Current = DefaultVariable()
|
var Current = DefaultVariable()
|
||||||
|
@ -31,6 +32,9 @@ type Variable struct {
|
||||||
BareRTC BareRTC
|
BareRTC BareRTC
|
||||||
Maintenance Maintenance
|
Maintenance Maintenance
|
||||||
Encryption Encryption
|
Encryption Encryption
|
||||||
|
SignedPhoto SignedPhoto
|
||||||
|
WebPush WebPush
|
||||||
|
Turnstile Turnstile
|
||||||
UseXForwardedFor bool
|
UseXForwardedFor bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,7 +66,7 @@ func LoadSettings() {
|
||||||
|
|
||||||
if _, err := os.Stat(SettingsPath); !os.IsNotExist(err) {
|
if _, err := os.Stat(SettingsPath); !os.IsNotExist(err) {
|
||||||
log.Info("Loading settings from %s", SettingsPath)
|
log.Info("Loading settings from %s", SettingsPath)
|
||||||
content, err := ioutil.ReadFile(SettingsPath)
|
content, err := os.ReadFile(SettingsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(fmt.Sprintf("LoadSettings: couldn't read settings.json: %s", err))
|
panic(fmt.Sprintf("LoadSettings: couldn't read settings.json: %s", err))
|
||||||
}
|
}
|
||||||
|
@ -97,6 +101,38 @@ func LoadSettings() {
|
||||||
writeSettings = true
|
writeSettings = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize the cold storage RSA keys.
|
||||||
|
if len(Current.Encryption.ColdStorageRSAPublicKey) == 0 {
|
||||||
|
x509publicKey, err := coldstorage.Initialize()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Initializing cold storage: %s", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the public key in the settings.json.
|
||||||
|
Current.Encryption.ColdStorageRSAPublicKey = x509publicKey
|
||||||
|
writeSettings = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the VAPID keys for Web Push Notification.
|
||||||
|
if len(Current.WebPush.VAPIDPublicKey) == 0 {
|
||||||
|
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Initializing VAPID keys for Web Push: %s", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Current.WebPush.VAPIDPrivateKey = privateKey
|
||||||
|
Current.WebPush.VAPIDPublicKey = publicKey
|
||||||
|
writeSettings = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize JWT token for SignedPhoto feature.
|
||||||
|
if Current.SignedPhoto.JWTSecret == "" {
|
||||||
|
Current.SignedPhoto.JWTSecret = uuid.New().String()
|
||||||
|
writeSettings = true
|
||||||
|
}
|
||||||
|
|
||||||
// Have we added new config fields? Save the settings.json.
|
// Have we added new config fields? Save the settings.json.
|
||||||
if Current.Version != currentVersion || writeSettings {
|
if Current.Version != currentVersion || writeSettings {
|
||||||
log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.")
|
log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.")
|
||||||
|
@ -119,7 +155,7 @@ func WriteSettings() error {
|
||||||
panic(fmt.Sprintf("WriteSettings: couldn't marshal settings: %s", err))
|
panic(fmt.Sprintf("WriteSettings: couldn't marshal settings: %s", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return ioutil.WriteFile(SettingsPath, buf.Bytes(), 0600)
|
return os.WriteFile(SettingsPath, buf.Bytes(), 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mail settings.
|
// Mail settings.
|
||||||
|
@ -163,5 +199,25 @@ type Maintenance struct {
|
||||||
|
|
||||||
// Encryption settings.
|
// Encryption settings.
|
||||||
type Encryption struct {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse notification filters.
|
||||||
|
nf := models.NewNotificationFilterFromForm(r)
|
||||||
|
|
||||||
// Get our notifications.
|
// Get our notifications.
|
||||||
pager := &models.Pagination{
|
pager := &models.Pagination{
|
||||||
Page: 1,
|
Page: 1,
|
||||||
PerPage: config.PageSizeDashboardNotifications,
|
PerPage: config.PageSizeDashboardNotifications,
|
||||||
Sort: "created_at desc",
|
Sort: "read, created_at desc",
|
||||||
}
|
}
|
||||||
pager.ParsePage(r)
|
pager.ParsePage(r)
|
||||||
notifs, err := models.PaginateNotifications(currentUser, pager)
|
notifs, err := models.PaginateNotifications(currentUser, nf, pager)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't get your notifications: %s", err)
|
session.FlashError(w, r, "Couldn't get your notifications: %s", err)
|
||||||
}
|
}
|
||||||
|
@ -86,6 +89,7 @@ func Dashboard() http.HandlerFunc {
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"Notifications": notifs,
|
"Notifications": notifs,
|
||||||
"NotifMap": notifMap,
|
"NotifMap": notifMap,
|
||||||
|
"Filters": nf,
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
|
|
||||||
// Show a warning to 'restricted' profiles who are especially private.
|
// Show a warning to 'restricted' profiles who are especially private.
|
||||||
|
|
|
@ -4,6 +4,8 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
@ -41,6 +43,14 @@ func Deactivate() http.HandlerFunc {
|
||||||
session.LogoutUser(w, r)
|
session.LogoutUser(w, r)
|
||||||
session.Flash(w, r, "Your account has been deactivated and you are now logged out. If you wish to re-activate your account, sign in again with your username and password.")
|
session.Flash(w, r, "Your account has been deactivated and you are now logged out. If you wish to re-activate your account, sign in again with your username and password.")
|
||||||
templates.Redirect(w, "/")
|
templates.Redirect(w, "/")
|
||||||
|
|
||||||
|
// Maybe kick them from chat if this deletion makes them into a Shy Account.
|
||||||
|
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
|
||||||
|
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogEvent(currentUser, nil, models.ChangeLogLifecycle, "users", currentUser.ID, "Deactivated their account.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,5 +88,8 @@ func Reactivate() http.HandlerFunc {
|
||||||
|
|
||||||
session.Flash(w, r, "Welcome back! Your account has been reactivated.")
|
session.Flash(w, r, "Welcome back! Your account has been reactivated.")
|
||||||
templates.Redirect(w, "/")
|
templates.Redirect(w, "/")
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogEvent(currentUser, nil, models.ChangeLogLifecycle, "users", currentUser.ID, "Reactivated their account.")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
package account
|
package account
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models/deletion"
|
"code.nonshy.com/nonshy/website/pkg/models/deletion"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
@ -40,6 +44,14 @@ func Delete() http.HandlerFunc {
|
||||||
session.LogoutUser(w, r)
|
session.LogoutUser(w, r)
|
||||||
session.Flash(w, r, "Your account has been deleted.")
|
session.Flash(w, r, "Your account has been deleted.")
|
||||||
templates.Redirect(w, "/")
|
templates.Redirect(w, "/")
|
||||||
|
|
||||||
|
// Kick them from the chat room if they are online.
|
||||||
|
if _, err := chat.DisconnectUserNow(currentUser, "You have been signed out of chat because you had deleted your account."); err != nil {
|
||||||
|
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogDeleted(nil, nil, "users", currentUser.ID, fmt.Sprintf("Username %s has deleted their account.", currentUser.Username), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,6 @@ package account
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
|
@ -10,18 +9,12 @@ import (
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
var UserFriendsRegexp = regexp.MustCompile(`^/friends/u/([^@]+?)$`)
|
|
||||||
|
|
||||||
// User friends page (/friends/u/username)
|
// User friends page (/friends/u/username)
|
||||||
func UserFriends() http.HandlerFunc {
|
func UserFriends() http.HandlerFunc {
|
||||||
tmpl := templates.Must("account/friends.html")
|
tmpl := templates.Must("account/friends.html")
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Parse the username out of the URL parameters.
|
// Parse the username out of the URL parameters.
|
||||||
var username string
|
var username = r.PathValue("username")
|
||||||
m := UserFriendsRegexp.FindStringSubmatch(r.URL.Path)
|
|
||||||
if m != nil {
|
|
||||||
username = m[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find this user.
|
// Find this user.
|
||||||
user, err := models.FindUser(username)
|
user, err := models.FindUser(username)
|
||||||
|
|
|
@ -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,
|
CooldownAt: config.LoginRateLimitCooldownAt,
|
||||||
Cooldown: config.LoginRateLimitCooldown,
|
Cooldown: config.LoginRateLimitCooldown,
|
||||||
}
|
}
|
||||||
|
var takebackDeferredError bool
|
||||||
if err := limiter.Ping(); err != nil {
|
if err := limiter.Ping(); err != nil {
|
||||||
session.FlashError(w, r, err.Error())
|
// Is it a deferred error? Flash it at the end of the request but continue
|
||||||
templates.Redirect(w, r.URL.Path)
|
// to process this login attempt as normal.
|
||||||
return
|
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.
|
// Look up their account.
|
||||||
|
@ -124,6 +137,9 @@ func Login() http.HandlerFunc {
|
||||||
log.Error("Failed to clear login rate limiter: %s", err)
|
log.Error("Failed to clear login rate limiter: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If there was going to be a deferred ratelimit error, take it back.
|
||||||
|
takebackDeferredError = true
|
||||||
|
|
||||||
// Redirect to their dashboard.
|
// Redirect to their dashboard.
|
||||||
session.Flash(w, r, "Login successful.")
|
session.Flash(w, r, "Login successful.")
|
||||||
if strings.HasPrefix(next, "/") {
|
if strings.HasPrefix(next, "/") {
|
||||||
|
|
|
@ -3,8 +3,9 @@ package account
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"strings"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/middleware"
|
"code.nonshy.com/nonshy/website/pkg/middleware"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
|
@ -13,18 +14,12 @@ import (
|
||||||
"code.nonshy.com/nonshy/website/pkg/worker"
|
"code.nonshy.com/nonshy/website/pkg/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ProfileRegexp = regexp.MustCompile(`^/u/([^@]+?)$`)
|
|
||||||
|
|
||||||
// User profile page (/u/username)
|
// User profile page (/u/username)
|
||||||
func Profile() http.HandlerFunc {
|
func Profile() http.HandlerFunc {
|
||||||
tmpl := templates.Must("account/profile.html")
|
tmpl := templates.Must("account/profile.html")
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Parse the username out of the URL parameters.
|
// Parse the username out of the URL parameters.
|
||||||
var username string
|
var username = r.PathValue("username")
|
||||||
m := ProfileRegexp.FindStringSubmatch(r.URL.Path)
|
|
||||||
if m != nil {
|
|
||||||
username = m[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find this user.
|
// Find this user.
|
||||||
user, err := models.FindUser(username)
|
user, err := models.FindUser(username)
|
||||||
|
@ -45,7 +40,8 @@ func Profile() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
vars := map[string]interface{}{
|
vars := map[string]interface{}{
|
||||||
"User": user,
|
"User": user,
|
||||||
|
"IsExternalView": true,
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
@ -77,23 +73,18 @@ func Profile() http.HandlerFunc {
|
||||||
// Inject relationship booleans for profile picture display.
|
// Inject relationship booleans for profile picture display.
|
||||||
models.SetUserRelationships(currentUser, []*models.User{user})
|
models.SetUserRelationships(currentUser, []*models.User{user})
|
||||||
|
|
||||||
// Admin user can always see the profile pic - but only on this page. Other avatar displays
|
// Admin user (photo moderator) can always see the profile pic - but only on this page.
|
||||||
// will show the yellow or pink shy.png if the admin is not friends or not granted.
|
// Other avatar displays will show the yellow or pink shy.png if the admin is not friends or not granted.
|
||||||
if currentUser.IsAdmin {
|
if currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||||
user.UserRelationship.IsFriend = true
|
user.UserRelationship.IsFriend = true
|
||||||
user.UserRelationship.IsPrivateGranted = true
|
user.UserRelationship.IsPrivateGranted = true
|
||||||
}
|
}
|
||||||
|
|
||||||
var isSelf = currentUser.ID == user.ID
|
var isSelf = currentUser.ID == user.ID
|
||||||
|
|
||||||
// Banned or disabled? Only admin can view then.
|
// Give a Not Found page if we can not see this user.
|
||||||
if user.Status != models.UserStatusActive && !currentUser.IsAdmin {
|
if err := user.CanBeSeenBy(currentUser); err != nil {
|
||||||
templates.NotFoundPage(w, r)
|
log.Error("%s can not be seen by viewer %s: %s", user.Username, currentUser.Username, err)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is either one blocking?
|
|
||||||
if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
|
|
||||||
templates.NotFoundPage(w, r)
|
templates.NotFoundPage(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -113,27 +104,32 @@ func Profile() http.HandlerFunc {
|
||||||
log.Error("WhoLikes(user %d): %s", user.ID, err)
|
log.Error("WhoLikes(user %d): %s", user.ID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chat Moderation Rule: count of rules applied to the user, for admin view.
|
||||||
|
var chatModerationRules int
|
||||||
|
if currentUser.HasAdminScope(config.ScopeChatModerator) {
|
||||||
|
if rules := user.GetProfileField("chat_moderation_rules"); len(rules) > 0 {
|
||||||
|
chatModerationRules = len(strings.Split(rules, ","))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
vars := map[string]interface{}{
|
vars := map[string]interface{}{
|
||||||
"User": user,
|
"User": user,
|
||||||
"LikeMap": likeMap,
|
"LikeMap": likeMap,
|
||||||
"IsFriend": isFriend,
|
"IsFriend": isFriend,
|
||||||
"IsPrivate": isPrivate,
|
"IsPrivate": isPrivate,
|
||||||
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
|
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
|
||||||
"NoteCount": models.CountNotesAboutUser(currentUser, user),
|
"NoteCount": models.CountNotesAboutUser(currentUser, user),
|
||||||
"FriendCount": models.CountFriends(user.ID),
|
"FriendCount": models.CountFriends(user.ID),
|
||||||
"ForumThreadCount": models.CountThreadsByUser(user),
|
"OnChat": worker.GetChatStatistics().IsOnline(user.Username),
|
||||||
"ForumReplyCount": models.CountCommentsByUser(user, "threads"),
|
|
||||||
"PhotoCommentCount": models.CountCommentsByUser(user, "photos"),
|
|
||||||
"CommentsReceivedCount": models.CountCommentsReceived(user),
|
|
||||||
"LikesGivenCount": models.CountLikesGiven(user),
|
|
||||||
"LikesReceivedCount": models.CountLikesReceived(user),
|
|
||||||
"OnChat": worker.GetChatStatistics().IsOnline(user.Username),
|
|
||||||
|
|
||||||
// Details on who likes their profile page.
|
// Details on who likes their profile page.
|
||||||
"LikeExample": likeExample,
|
"LikeExample": likeExample,
|
||||||
"LikeRemainder": likeRemainder,
|
"LikeRemainder": likeRemainder,
|
||||||
"LikeTableName": "users",
|
"LikeTableName": "users",
|
||||||
"LikeTableID": user.ID,
|
"LikeTableID": user.ID,
|
||||||
|
|
||||||
|
// Admin numbers.
|
||||||
|
"NumChatModerationRules": chatModerationRules,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
|
|
@ -135,17 +135,25 @@ func ForgotPassword() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email them their reset link.
|
// Email them their reset link -- if not banned.
|
||||||
if err := mail.Send(mail.Message{
|
if !user.IsBanned() {
|
||||||
To: user.Email,
|
if err := mail.LockSending("reset_password", user.Email, config.EmailDebounceResetPassword); err == nil {
|
||||||
Subject: "Reset your forgotten password",
|
if err := mail.Send(mail.Message{
|
||||||
Template: "email/reset_password.html",
|
To: user.Email,
|
||||||
Data: map[string]interface{}{
|
Subject: "Reset your forgotten password",
|
||||||
"Username": user.Username,
|
Template: "email/reset_password.html",
|
||||||
"URL": config.Current.BaseURL + "/forgot-password?token=" + token.Token,
|
Data: map[string]interface{}{
|
||||||
},
|
"Username": user.Username,
|
||||||
}); err != nil {
|
"URL": config.Current.BaseURL + "/forgot-password?token=" + token.Token,
|
||||||
session.FlashError(w, r, "Error sending an email: %s", err)
|
},
|
||||||
|
}); err != nil {
|
||||||
|
session.FlashError(w, r, "Error sending an email: %s", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Error("LockSending: reset_password e-mail is not sent to %s: one was sent recently", user.Email)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Error("Do not send 'forgot password' e-mail to %s: user is banned", user.Email)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success message and redirect away.
|
// Success message and redirect away.
|
||||||
|
|
|
@ -1,14 +1,17 @@
|
||||||
package account
|
package account
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/controller/chat"
|
||||||
"code.nonshy.com/nonshy/website/pkg/geoip"
|
"code.nonshy.com/nonshy/website/pkg/geoip"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/spam"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
"code.nonshy.com/nonshy/website/pkg/worker"
|
"code.nonshy.com/nonshy/website/pkg/worker"
|
||||||
)
|
)
|
||||||
|
@ -21,6 +24,7 @@ func Search() http.HandlerFunc {
|
||||||
var sortWhitelist = []string{
|
var sortWhitelist = []string{
|
||||||
"last_login_at desc",
|
"last_login_at desc",
|
||||||
"created_at desc",
|
"created_at desc",
|
||||||
|
"certified_at desc",
|
||||||
"username",
|
"username",
|
||||||
"username desc",
|
"username desc",
|
||||||
"lower(name)",
|
"lower(name)",
|
||||||
|
@ -32,12 +36,16 @@ func Search() http.HandlerFunc {
|
||||||
// Search filters.
|
// Search filters.
|
||||||
var (
|
var (
|
||||||
isCertified = r.FormValue("certified")
|
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")
|
gender = r.FormValue("gender")
|
||||||
orientation = r.FormValue("orientation")
|
orientation = r.FormValue("orientation")
|
||||||
maritalStatus = r.FormValue("marital_status")
|
maritalStatus = r.FormValue("marital_status")
|
||||||
hereFor = r.FormValue("here_for")
|
hereFor = r.FormValue("here_for")
|
||||||
friendSearch = r.FormValue("friends") == "true"
|
friendSearch = r.FormValue("friends") == "true"
|
||||||
|
likedSearch = r.FormValue("liked") == "true"
|
||||||
|
onChatSearch = r.FormValue("on_chat") == "true"
|
||||||
sort = r.FormValue("sort")
|
sort = r.FormValue("sort")
|
||||||
sortOK bool
|
sortOK bool
|
||||||
)
|
)
|
||||||
|
@ -48,6 +56,9 @@ func Search() http.HandlerFunc {
|
||||||
ageMin, ageMax = ageMax, ageMin
|
ageMin, ageMax = ageMax, ageMin
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rawSearch := models.ParseSearchString(searchTerm)
|
||||||
|
search, restricted := spam.RestrictSearchTerms(rawSearch)
|
||||||
|
|
||||||
// Get current user.
|
// Get current user.
|
||||||
currentUser, err := session.CurrentUser(r)
|
currentUser, err := session.CurrentUser(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -56,6 +67,32 @@ func Search() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Report when search terms are restricted.
|
||||||
|
if restricted != nil {
|
||||||
|
// Admin users: allow the search anyway.
|
||||||
|
if currentUser.IsAdmin {
|
||||||
|
search = rawSearch
|
||||||
|
} else {
|
||||||
|
fb := &models.Feedback{
|
||||||
|
Intent: "report",
|
||||||
|
Subject: "Search Keyword Blacklist",
|
||||||
|
UserID: currentUser.ID,
|
||||||
|
TableName: "users",
|
||||||
|
TableID: currentUser.ID,
|
||||||
|
Message: fmt.Sprintf(
|
||||||
|
"A user has run a search on the Member Directory using search terms which are prohibited.\n\n"+
|
||||||
|
"Their search query was: %s",
|
||||||
|
searchTerm,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the feedback.
|
||||||
|
if err := models.CreateFeedback(fb); err != nil {
|
||||||
|
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Geolocation/Who's Nearby: if the current user uses GeoIP, update
|
// Geolocation/Who's Nearby: if the current user uses GeoIP, update
|
||||||
// their coordinates now.
|
// their coordinates now.
|
||||||
myLocation, err := models.RefreshGeoIP(currentUser.ID, r)
|
myLocation, err := models.RefreshGeoIP(currentUser.ID, r)
|
||||||
|
@ -63,6 +100,24 @@ func Search() http.HandlerFunc {
|
||||||
log.Error("RefreshGeoIP: %s", err)
|
log.Error("RefreshGeoIP: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Are they doing a Location search (from world city typeahead)?
|
||||||
|
var city *models.WorldCities
|
||||||
|
if citySearch != "" {
|
||||||
|
sort = "distance"
|
||||||
|
|
||||||
|
// Require the current user to have THEIR location set, for fairness.
|
||||||
|
if myLocation.Source == models.LocationSourceNone {
|
||||||
|
session.FlashError(w, r, "You must set your own location before you can search for others by their location.")
|
||||||
|
} else {
|
||||||
|
// Look up the coordinates of their search.
|
||||||
|
city, err = models.FindWorldCity(citySearch)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Location search: no match was found for '%s', please use one of the exact search results from the type-ahead on the Location field.", citySearch)
|
||||||
|
citySearch = "" // null out their search
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sort options.
|
// Sort options.
|
||||||
for _, v := range sortWhitelist {
|
for _, v := range sortWhitelist {
|
||||||
if sort == v {
|
if sort == v {
|
||||||
|
@ -74,11 +129,39 @@ func Search() http.HandlerFunc {
|
||||||
sort = "last_login_at desc"
|
sort = "last_login_at desc"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Real name for certified_at
|
||||||
|
if sort == "certified_at desc" {
|
||||||
|
sort = "certification_photos.updated_at desc"
|
||||||
|
}
|
||||||
|
|
||||||
// Default
|
// Default
|
||||||
if isCertified == "" {
|
if isCertified == "" {
|
||||||
isCertified = "true"
|
isCertified = "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always filter for certified-only users unless the request specifically looked for non-certified.
|
||||||
|
// Searches for disabled/banned users (admin only) should also reveal ALL users including non-certified.
|
||||||
|
var certifiedOnly = true
|
||||||
|
if isCertified == "false" || isCertified == "all" || isCertified == "disabled" || isCertified == "banned" {
|
||||||
|
certifiedOnly = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-admin view: always hide non-certified profiles, they can be unsafe (fake profiles, scams if they won't certify)
|
||||||
|
if !currentUser.IsAdmin {
|
||||||
|
certifiedOnly = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Are we filtering for "On Chat?"
|
||||||
|
var inUsername = []string{}
|
||||||
|
if onChatSearch {
|
||||||
|
stats := chat.FilteredChatStatistics(currentUser)
|
||||||
|
inUsername = stats.Usernames
|
||||||
|
if len(inUsername) == 0 {
|
||||||
|
session.FlashError(w, r, "Notice: you wanted to filter by people currently on the chat room, but nobody is on chat at this time.")
|
||||||
|
inUsername = []string{"@"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pager := &models.Pagination{
|
pager := &models.Pagination{
|
||||||
PerPage: config.PageSizeMemberSearch,
|
PerPage: config.PageSizeMemberSearch,
|
||||||
Sort: sort,
|
Sort: sort,
|
||||||
|
@ -87,21 +170,26 @@ func Search() http.HandlerFunc {
|
||||||
|
|
||||||
users, err := models.SearchUsers(currentUser, &models.UserSearch{
|
users, err := models.SearchUsers(currentUser, &models.UserSearch{
|
||||||
Username: username,
|
Username: username,
|
||||||
|
InUsername: inUsername,
|
||||||
Gender: gender,
|
Gender: gender,
|
||||||
Orientation: orientation,
|
Orientation: orientation,
|
||||||
MaritalStatus: maritalStatus,
|
MaritalStatus: maritalStatus,
|
||||||
HereFor: hereFor,
|
HereFor: hereFor,
|
||||||
Certified: isCertified == "true",
|
ProfileText: search,
|
||||||
|
Certified: certifiedOnly,
|
||||||
|
NearCity: city,
|
||||||
NotCertified: isCertified == "false",
|
NotCertified: isCertified == "false",
|
||||||
InnerCircle: isCertified == "circle",
|
|
||||||
ShyAccounts: isCertified == "shy",
|
ShyAccounts: isCertified == "shy",
|
||||||
IsBanned: isCertified == "banned",
|
IsBanned: isCertified == "banned",
|
||||||
|
IsDisabled: isCertified == "disabled",
|
||||||
|
IsAdmin: isCertified == "admin",
|
||||||
Friends: friendSearch,
|
Friends: friendSearch,
|
||||||
|
Liked: likedSearch,
|
||||||
AgeMin: ageMin,
|
AgeMin: ageMin,
|
||||||
AgeMax: ageMax,
|
AgeMax: ageMax,
|
||||||
}, pager)
|
}, pager)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't search users: %s", err)
|
session.FlashError(w, r, "An error has occurred: %s.", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Who's Nearby feature, get some data.
|
// Who's Nearby feature, get some data.
|
||||||
|
@ -109,8 +197,16 @@ func Search() http.HandlerFunc {
|
||||||
|
|
||||||
// Collect usernames to map to chat online status.
|
// Collect usernames to map to chat online status.
|
||||||
var usernames = []string{}
|
var usernames = []string{}
|
||||||
|
var userIDs = []uint64{}
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
usernames = append(usernames, user.Username)
|
usernames = append(usernames, user.Username)
|
||||||
|
userIDs = append(userIDs, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User IDs of these I have "Liked"
|
||||||
|
likedIDs, err := models.LikedIDs(currentUser, "users", userIDs)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("LikedIDs: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
|
@ -125,19 +221,27 @@ func Search() http.HandlerFunc {
|
||||||
"MaritalStatus": maritalStatus,
|
"MaritalStatus": maritalStatus,
|
||||||
"HereFor": hereFor,
|
"HereFor": hereFor,
|
||||||
"EmailOrUsername": username,
|
"EmailOrUsername": username,
|
||||||
|
"Search": searchTerm,
|
||||||
|
"City": citySearch,
|
||||||
"AgeMin": ageMin,
|
"AgeMin": ageMin,
|
||||||
"AgeMax": ageMax,
|
"AgeMax": ageMax,
|
||||||
"FriendSearch": friendSearch,
|
"FriendSearch": friendSearch,
|
||||||
|
"LikedSearch": likedSearch,
|
||||||
|
"OnChatSearch": onChatSearch,
|
||||||
"Sort": sort,
|
"Sort": sort,
|
||||||
|
|
||||||
|
// Restricted Search errors.
|
||||||
|
"RestrictedSearchError": restricted,
|
||||||
|
|
||||||
// Photo counts mapped to users
|
// Photo counts mapped to users
|
||||||
"PhotoCountMap": models.MapPhotoCounts(users),
|
"PhotoCountMap": models.MapPhotoCounts(users),
|
||||||
|
|
||||||
// Map Shy Account badges for these results
|
// Map Shy Account badges for these results
|
||||||
"ShyMap": models.MapShyAccounts(users),
|
"ShyMap": models.MapShyAccounts(users),
|
||||||
|
|
||||||
// Map friendships to these users.
|
// Map friendships and likes to these users.
|
||||||
"FriendMap": models.MapFriends(currentUser, users),
|
"FriendMap": models.MapFriends(currentUser, users),
|
||||||
|
"LikedMap": models.MapLikes(currentUser, "users", likedIDs),
|
||||||
|
|
||||||
// Users on the chat room map.
|
// Users on the chat room map.
|
||||||
"UserOnChatMap": worker.GetChatStatistics().MapUsersOnline(usernames),
|
"UserOnChatMap": worker.GetChatStatistics().MapUsersOnline(usernames),
|
||||||
|
@ -145,7 +249,7 @@ func Search() http.HandlerFunc {
|
||||||
// Current user's location setting.
|
// Current user's location setting.
|
||||||
"MyLocation": myLocation,
|
"MyLocation": myLocation,
|
||||||
"GeoIPInsights": insights,
|
"GeoIPInsights": insights,
|
||||||
"DistanceMap": models.MapDistances(currentUser, users),
|
"DistanceMap": models.MapDistances(currentUser, city, users),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"code.nonshy.com/nonshy/website/pkg/geoip"
|
"code.nonshy.com/nonshy/website/pkg/geoip"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
@ -16,8 +17,10 @@ import (
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/redis"
|
"code.nonshy.com/nonshy/website/pkg/redis"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/spam"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
"code.nonshy.com/nonshy/website/pkg/utility"
|
"code.nonshy.com/nonshy/website/pkg/utility"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/worker"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -50,11 +53,19 @@ func Settings() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Is the user currently in the chat room? Gate username changes when so.
|
||||||
|
var isOnChat = worker.GetChatStatistics().IsOnline(user.Username)
|
||||||
|
vars["OnChat"] = isOnChat
|
||||||
|
|
||||||
// URL hashtag to redirect to
|
// URL hashtag to redirect to
|
||||||
var hashtag string
|
var hashtag string
|
||||||
|
|
||||||
// Are we POSTing?
|
// Are we POSTing?
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
|
|
||||||
|
// Will they BECOME a Shy Account with this change?
|
||||||
|
var wasShy = user.IsShy()
|
||||||
|
|
||||||
intent := r.PostFormValue("intent")
|
intent := r.PostFormValue("intent")
|
||||||
switch intent {
|
switch intent {
|
||||||
case "profile":
|
case "profile":
|
||||||
|
@ -108,7 +119,15 @@ func Settings() http.HandlerFunc {
|
||||||
|
|
||||||
// Set profile attributes.
|
// Set profile attributes.
|
||||||
for _, attr := range config.ProfileFields {
|
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.
|
// "Looking For" checkbox list.
|
||||||
|
@ -167,6 +186,7 @@ func Settings() http.HandlerFunc {
|
||||||
for _, field := range []string{
|
for _, field := range []string{
|
||||||
"hero-text-dark",
|
"hero-text-dark",
|
||||||
"card-lightness",
|
"card-lightness",
|
||||||
|
"website-theme",
|
||||||
} {
|
} {
|
||||||
value := r.PostFormValue(field)
|
value := r.PostFormValue(field)
|
||||||
user.SetProfileField(field, value)
|
user.SetProfileField(field, value)
|
||||||
|
@ -204,6 +224,7 @@ func Settings() http.HandlerFunc {
|
||||||
var (
|
var (
|
||||||
visibility = models.UserVisibility(r.PostFormValue("visibility"))
|
visibility = models.UserVisibility(r.PostFormValue("visibility"))
|
||||||
dmPrivacy = r.PostFormValue("dm_privacy")
|
dmPrivacy = r.PostFormValue("dm_privacy")
|
||||||
|
ppPrivacy = r.PostFormValue("private_photo_gate")
|
||||||
)
|
)
|
||||||
|
|
||||||
user.Visibility = models.UserVisibilityPublic
|
user.Visibility = models.UserVisibilityPublic
|
||||||
|
@ -216,6 +237,7 @@ func Settings() http.HandlerFunc {
|
||||||
|
|
||||||
// Set profile field prefs.
|
// Set profile field prefs.
|
||||||
user.SetProfileField("dm_privacy", dmPrivacy)
|
user.SetProfileField("dm_privacy", dmPrivacy)
|
||||||
|
user.SetProfileField("private_photo_gate", ppPrivacy)
|
||||||
|
|
||||||
if err := user.Save(); err != nil {
|
if err := user.Save(); err != nil {
|
||||||
session.FlashError(w, r, "Failed to save user to database: %s", err)
|
session.FlashError(w, r, "Failed to save user to database: %s", err)
|
||||||
|
@ -260,6 +282,28 @@ func Settings() http.HandlerFunc {
|
||||||
session.Flash(w, r, "Unsubscribed from all comment threads!")
|
session.Flash(w, r, "Unsubscribed from all comment threads!")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "push_notifications":
|
||||||
|
hashtag = "#notifications"
|
||||||
|
|
||||||
|
// Store their notification opt-outs.
|
||||||
|
for _, key := range config.PushNotificationOptOutFields {
|
||||||
|
var value = r.PostFormValue(key)
|
||||||
|
|
||||||
|
if value == "" {
|
||||||
|
value = "true" // opt-out, store opt-out=true in the DB
|
||||||
|
} else if value == "true" {
|
||||||
|
value = "false" // the box remained checked, they don't opt-out, store opt-out=false in the DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save it.
|
||||||
|
user.SetProfileField(key, value)
|
||||||
|
}
|
||||||
|
session.Flash(w, r, "Notification preferences updated!")
|
||||||
|
|
||||||
|
// Save the user for new fields to be committed to DB.
|
||||||
|
if err := user.Save(); err != nil {
|
||||||
|
session.FlashError(w, r, "Failed to save user to database: %s", err)
|
||||||
|
}
|
||||||
case "location":
|
case "location":
|
||||||
hashtag = "#location"
|
hashtag = "#location"
|
||||||
var (
|
var (
|
||||||
|
@ -293,32 +337,91 @@ func Settings() http.HandlerFunc {
|
||||||
case "settings":
|
case "settings":
|
||||||
hashtag = "#account"
|
hashtag = "#account"
|
||||||
var (
|
var (
|
||||||
oldPassword = r.PostFormValue("old_password")
|
oldPassword = r.PostFormValue("old_password")
|
||||||
changeEmail = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_email")))
|
changeEmail = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_email")))
|
||||||
password1 = strings.TrimSpace(r.PostFormValue("new_password"))
|
changeUsername = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_username")))
|
||||||
password2 = strings.TrimSpace(r.PostFormValue("new_password2"))
|
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.
|
// Their old password is needed to make any changes to their account.
|
||||||
if err := user.CheckPassword(oldPassword); err != nil {
|
if err := user.CheckPassword(oldPassword); err != nil {
|
||||||
session.FlashError(w, r, "Could not make changes to your account settings as the 'current password' you entered was incorrect.")
|
session.FlashError(w, r, "Could not make changes to your account settings as the 'current password' you entered was incorrect.")
|
||||||
templates.Redirect(w, r.URL.Path)
|
templates.Redirect(w, r.URL.Path+hashtag)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Changing their username?
|
||||||
|
if changeUsername != user.Username {
|
||||||
|
// Not if they are in the chat room!
|
||||||
|
if isOnChat {
|
||||||
|
session.FlashError(w, r, "Your username could not be changed right now because you are logged into the chat room. Please exit the chat room, wait a minute, and try your request again.")
|
||||||
|
templates.Redirect(w, r.URL.Path+hashtag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the new name is OK.
|
||||||
|
if err := models.IsValidUsername(changeUsername); err != nil {
|
||||||
|
session.FlashError(w, r, "Could not change your username: %s", err.Error())
|
||||||
|
templates.Redirect(w, r.URL.Path+hashtag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear their history on the chat room.
|
||||||
|
go func(username string) {
|
||||||
|
log.Error("Change of username, clear chat history for old name %s", username)
|
||||||
|
i, err := chat.EraseChatHistory(username)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("EraseChatHistory(%s): %s", username, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Flash(w, r, "Notice: due to your recent change in username, your direct message history on the Chat Room has been reset. %d message(s) had been removed.", i)
|
||||||
|
}(user.Username)
|
||||||
|
|
||||||
|
// Set their name.
|
||||||
|
origUsername := user.Username
|
||||||
|
user.Username = changeUsername
|
||||||
|
if err := user.Save(); err != nil {
|
||||||
|
session.FlashError(w, r, "Error saving your new username: %s", err)
|
||||||
|
} else {
|
||||||
|
session.Flash(w, r, "Your username has been updated to: %s", user.Username)
|
||||||
|
|
||||||
|
// Notify the admin about this to keep tabs if someone is acting strangely
|
||||||
|
// with too-frequent username changes.
|
||||||
|
fb := &models.Feedback{
|
||||||
|
Intent: "report",
|
||||||
|
Subject: "Change of username",
|
||||||
|
UserID: user.ID,
|
||||||
|
TableName: "users",
|
||||||
|
TableID: user.ID,
|
||||||
|
Message: fmt.Sprintf(
|
||||||
|
"A user has modified their username on their profile page!\n\n"+
|
||||||
|
"* Original: %s\n* Updated: %s",
|
||||||
|
origUsername, changeUsername,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the feedback.
|
||||||
|
if err := models.CreateFeedback(fb); err != nil {
|
||||||
|
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Changing their email?
|
// Changing their email?
|
||||||
if changeEmail != user.Email {
|
if changeEmail != user.Email {
|
||||||
// Validate the email.
|
// Validate the email.
|
||||||
if _, err := nm.ParseAddress(changeEmail); err != nil {
|
if _, err := nm.ParseAddress(changeEmail); err != nil {
|
||||||
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
|
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
|
||||||
templates.Redirect(w, r.URL.Path)
|
templates.Redirect(w, r.URL.Path+hashtag)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email must not already exist.
|
// Email must not already exist.
|
||||||
if _, err := models.FindUser(changeEmail); err == nil {
|
if _, err := models.FindUser(changeEmail); err == nil {
|
||||||
session.FlashError(w, r, "That email address is already in use.")
|
session.FlashError(w, r, "That email address is already in use.")
|
||||||
templates.Redirect(w, r.URL.Path)
|
templates.Redirect(w, r.URL.Path+hashtag)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -330,7 +433,7 @@ func Settings() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
if err := redis.Set(fmt.Sprintf(config.ChangeEmailRedisKey, token.Token), token, config.SignupTokenExpires); err != nil {
|
if err := redis.Set(fmt.Sprintf(config.ChangeEmailRedisKey, token.Token), token, config.SignupTokenExpires); err != nil {
|
||||||
session.FlashError(w, r, "Failed to create change email token: %s", err)
|
session.FlashError(w, r, "Failed to create change email token: %s", err)
|
||||||
templates.Redirect(w, r.URL.Path)
|
templates.Redirect(w, r.URL.Path+hashtag)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -374,6 +477,13 @@ func Settings() http.HandlerFunc {
|
||||||
session.FlashError(w, r, "Unknown POST intent value. Please try again.")
|
session.FlashError(w, r, "Unknown POST intent value. Please try again.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Maybe kick them from the chat room if they had become a Shy Account.
|
||||||
|
if !wasShy && user.IsShy() {
|
||||||
|
if _, err := chat.MaybeDisconnectUser(user); err != nil {
|
||||||
|
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
templates.Redirect(w, r.URL.Path+hashtag+".")
|
templates.Redirect(w, r.URL.Path+hashtag+".")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -392,6 +502,9 @@ func Settings() http.HandlerFunc {
|
||||||
// Count of subscribed comment threads.
|
// Count of subscribed comment threads.
|
||||||
vars["SubscriptionCount"] = models.CountSubscriptions(user)
|
vars["SubscriptionCount"] = models.CountSubscriptions(user)
|
||||||
|
|
||||||
|
// Count of push notification subscriptions.
|
||||||
|
vars["PushNotificationsCount"] = models.CountPushNotificationSubscriptions(user)
|
||||||
|
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/redis"
|
"code.nonshy.com/nonshy/website/pkg/redis"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/spam"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
"code.nonshy.com/nonshy/website/pkg/utility"
|
"code.nonshy.com/nonshy/website/pkg/utility"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
@ -60,7 +61,6 @@ func Signup() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
var token SignupToken
|
var token SignupToken
|
||||||
log.Info("SignupToken: %s", tokenStr)
|
|
||||||
if tokenStr != "" {
|
if tokenStr != "" {
|
||||||
// Validate it.
|
// Validate it.
|
||||||
if err := redis.Get(fmt.Sprintf(config.SignupTokenRedisKey, tokenStr), &token); err != nil || token.Token != tokenStr {
|
if err := redis.Get(fmt.Sprintf(config.SignupTokenRedisKey, tokenStr), &token); err != nil || token.Token != tokenStr {
|
||||||
|
@ -72,7 +72,6 @@ func Signup() http.HandlerFunc {
|
||||||
vars["SignupToken"] = tokenStr
|
vars["SignupToken"] = tokenStr
|
||||||
vars["Email"] = token.Email
|
vars["Email"] = token.Email
|
||||||
}
|
}
|
||||||
log.Info("Vars: %+v", vars)
|
|
||||||
|
|
||||||
// Posting?
|
// Posting?
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
|
@ -86,8 +85,34 @@ func Signup() http.HandlerFunc {
|
||||||
password = strings.TrimSpace(r.PostFormValue("password"))
|
password = strings.TrimSpace(r.PostFormValue("password"))
|
||||||
password2 = strings.TrimSpace(r.PostFormValue("password2"))
|
password2 = strings.TrimSpace(r.PostFormValue("password2"))
|
||||||
dob = r.PostFormValue("dob")
|
dob = r.PostFormValue("dob")
|
||||||
|
|
||||||
|
// CAPTCHA response.
|
||||||
|
turnstileCAPTCHA = r.PostFormValue("cf-turnstile-response")
|
||||||
|
|
||||||
|
// Honeytrap fields for lazy spam bots.
|
||||||
|
honeytrap1 = r.PostFormValue("phone") == ""
|
||||||
|
honeytrap2 = r.PostFormValue("referral") == "Word of mouth"
|
||||||
|
|
||||||
|
// Validation errors but still show the form again.
|
||||||
|
hasError bool
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Honeytrap fields check.
|
||||||
|
if !honeytrap1 || !honeytrap2 {
|
||||||
|
session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email)
|
||||||
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the CAPTCHA token.
|
||||||
|
if config.Current.Turnstile.Enabled {
|
||||||
|
if err := spam.ValidateTurnstileCAPTCHA(turnstileCAPTCHA, "signup"); err != nil {
|
||||||
|
session.FlashError(w, r, "There was an error validating your CAPTCHA response.")
|
||||||
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Don't let them sneakily change their verified email address on us.
|
// Don't let them sneakily change their verified email address on us.
|
||||||
if vars["SignupToken"] != "" && email != vars["Email"] {
|
if vars["SignupToken"] != "" && email != vars["Email"] {
|
||||||
session.FlashError(w, r, "This email address is not verified. Please start over from the beginning.")
|
session.FlashError(w, r, "This email address is not verified. Please start over from the beginning.")
|
||||||
|
@ -95,27 +120,10 @@ func Signup() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reserved username check.
|
|
||||||
for _, cmp := range config.ReservedUsernames {
|
|
||||||
if username == cmp {
|
|
||||||
session.FlashError(w, r, "That username is reserved, please choose a different username.")
|
|
||||||
templates.Redirect(w, r.URL.Path+"?token="+tokenStr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache username in case of passwd validation errors.
|
// Cache username in case of passwd validation errors.
|
||||||
vars["Email"] = email
|
vars["Email"] = email
|
||||||
vars["Username"] = username
|
vars["Username"] = username
|
||||||
|
|
||||||
// Is the app not configured to send email?
|
|
||||||
if !config.Current.Mail.Enabled {
|
|
||||||
session.FlashError(w, r, "This app is not configured to send email so you can not sign up at this time. "+
|
|
||||||
"Please contact the website administrator about this issue!")
|
|
||||||
templates.Redirect(w, r.URL.Path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the email.
|
// Validate the email.
|
||||||
if _, err := nm.ParseAddress(email); err != nil {
|
if _, err := nm.ParseAddress(email); err != nil {
|
||||||
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
|
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
|
||||||
|
@ -131,20 +139,29 @@ func Signup() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already an account?
|
// 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
|
// We don't want to admit that the email already is registered, so send an email to the
|
||||||
// address in case the user legitimately forgot, but flash the regular success message.
|
// address in case the user legitimately forgot, but flash the regular success message.
|
||||||
err := mail.Send(mail.Message{
|
if user.IsBanned() {
|
||||||
To: email,
|
log.Error("Do not send signup e-mail to %s: user is banned", email)
|
||||||
Subject: "You already have a nonshy account",
|
} else {
|
||||||
Template: "email/already_signed_up.html",
|
if err := mail.LockSending("signup", email, config.EmailDebounceDefault); err == nil {
|
||||||
Data: map[string]interface{}{
|
err := mail.Send(mail.Message{
|
||||||
"Title": config.Title,
|
To: email,
|
||||||
"URL": config.Current.BaseURL + "/forgot-password",
|
Subject: "You already have a nonshy account",
|
||||||
},
|
Template: "email/already_signed_up.html",
|
||||||
})
|
Data: map[string]interface{}{
|
||||||
if err != nil {
|
"Title": config.Title,
|
||||||
session.FlashError(w, r, "Error sending an email: %s", err)
|
"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)
|
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)
|
session.FlashError(w, r, "Error creating a link to send you: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := mail.Send(mail.Message{
|
// Is the app not configured to send email?
|
||||||
To: email,
|
if !config.Current.Mail.Enabled && !config.SkipEmailVerification {
|
||||||
Subject: "Verify your e-mail address",
|
// Log the signup token for local dev.
|
||||||
Template: "email/verify_email.html",
|
log.Error("Signup: the app is not configured to send email. To continue, visit the URL: /signup?token=%s", token.Token)
|
||||||
Data: map[string]interface{}{
|
session.FlashError(w, r, "This app is not configured to send email so you can not sign up at this time. "+
|
||||||
"Title": config.Title,
|
"Please contact the website administrator about this issue!")
|
||||||
"URL": config.Current.BaseURL + "/signup?token=" + token.Token,
|
templates.Redirect(w, r.URL.Path)
|
||||||
},
|
return
|
||||||
})
|
}
|
||||||
if err != nil {
|
|
||||||
session.FlashError(w, r, "Error sending an email: %s", err)
|
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)
|
session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email)
|
||||||
|
|
||||||
|
// Reminder to check their spam folder too (Gmail users)
|
||||||
|
session.Flash(w, r, "If you don't see the confirmation e-mail, check in case it went to your spam folder.")
|
||||||
|
|
||||||
templates.Redirect(w, r.URL.Path)
|
templates.Redirect(w, r.URL.Path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -205,7 +240,6 @@ func Signup() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full sign-up step (w/ email verification token), validate more things.
|
// Full sign-up step (w/ email verification token), validate more things.
|
||||||
var hasError bool
|
|
||||||
if len(password) < 3 {
|
if len(password) < 3 {
|
||||||
session.FlashError(w, r, "Please enter a password longer than 3 characters.")
|
session.FlashError(w, r, "Please enter a password longer than 3 characters.")
|
||||||
hasError = true
|
hasError = true
|
||||||
|
@ -214,8 +248,9 @@ func Signup() http.HandlerFunc {
|
||||||
hasError = true
|
hasError = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if !config.UsernameRegexp.MatchString(username) {
|
// Validate the username is OK: well formatted, not reserved, not existing.
|
||||||
session.FlashError(w, r, "Your username must consist of only numbers, letters, - . and be 3-32 characters.")
|
if err := models.IsValidUsername(username); err != nil {
|
||||||
|
session.FlashError(w, r, err.Error())
|
||||||
hasError = true
|
hasError = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ package account
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
@ -14,18 +13,15 @@ import (
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
var NotesURLRegexp = regexp.MustCompile(`^/notes/u/([^@]+?)$`)
|
|
||||||
|
|
||||||
// User notes page (/notes/u/username)
|
// User notes page (/notes/u/username)
|
||||||
func UserNotes() http.HandlerFunc {
|
func UserNotes() http.HandlerFunc {
|
||||||
tmpl := templates.Must("account/user_notes.html")
|
tmpl := templates.Must("account/user_notes.html")
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Parse the username out of the URL parameters.
|
// Parse the username out of the URL parameters.
|
||||||
var username string
|
var (
|
||||||
m := NotesURLRegexp.FindStringSubmatch(r.URL.Path)
|
username = r.PathValue("username")
|
||||||
if m != nil {
|
show = r.FormValue("show") // admin feedback filter
|
||||||
username = m[1]
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// Find this user.
|
// Find this user.
|
||||||
user, err := models.FindUser(username)
|
user, err := models.FindUser(username)
|
||||||
|
@ -115,7 +111,7 @@ func UserNotes() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paginate feedback & reports.
|
// 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)
|
session.FlashError(w, r, "Paginating feedback on this user: %s", err)
|
||||||
} else {
|
} else {
|
||||||
feedback = fb
|
feedback = fb
|
||||||
|
@ -148,6 +144,7 @@ func UserNotes() http.HandlerFunc {
|
||||||
"MyNote": myNote,
|
"MyNote": myNote,
|
||||||
|
|
||||||
// Admin concerns.
|
// Admin concerns.
|
||||||
|
"Show": show,
|
||||||
"Feedback": feedback,
|
"Feedback": feedback,
|
||||||
"FeedbackPager": fbPager,
|
"FeedbackPager": fbPager,
|
||||||
"OtherNotes": otherNotes,
|
"OtherNotes": otherNotes,
|
||||||
|
@ -208,7 +205,7 @@ func MyNotes() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin notes?
|
// Admin notes?
|
||||||
if adminNotes && !currentUser.IsAdmin {
|
if adminNotes && !currentUser.HasAdminScope(config.ScopeUserNotes) {
|
||||||
adminNotes = false
|
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 (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/encryption/coldstorage"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -10,7 +11,10 @@ import (
|
||||||
func Dashboard() http.HandlerFunc {
|
func Dashboard() http.HandlerFunc {
|
||||||
tmpl := templates.Must("admin/dashboard.html")
|
tmpl := templates.Must("admin/dashboard.html")
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
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)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,13 @@ import (
|
||||||
// Feedback controller (/admin/feedback)
|
// Feedback controller (/admin/feedback)
|
||||||
func Feedback() http.HandlerFunc {
|
func Feedback() http.HandlerFunc {
|
||||||
tmpl := templates.Must("admin/feedback.html")
|
tmpl := templates.Must("admin/feedback.html")
|
||||||
|
|
||||||
|
// Whitelist for ordering options.
|
||||||
|
var sortWhitelist = []string{
|
||||||
|
"created_at desc",
|
||||||
|
"created_at asc",
|
||||||
|
}
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Query params.
|
// Query params.
|
||||||
var (
|
var (
|
||||||
|
@ -23,8 +30,26 @@ func Feedback() http.HandlerFunc {
|
||||||
profile = r.FormValue("profile") == "true" // visit associated user profile
|
profile = r.FormValue("profile") == "true" // visit associated user profile
|
||||||
verdict = r.FormValue("verdict")
|
verdict = r.FormValue("verdict")
|
||||||
fb *models.Feedback
|
fb *models.Feedback
|
||||||
|
|
||||||
|
// Search filters.
|
||||||
|
searchQuery = r.FormValue("q")
|
||||||
|
search = models.ParseSearchString(searchQuery)
|
||||||
|
subject = r.FormValue("subject")
|
||||||
|
sort = r.FormValue("sort")
|
||||||
|
sortOK bool
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Sort options.
|
||||||
|
for _, v := range sortWhitelist {
|
||||||
|
if sort == v {
|
||||||
|
sortOK = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sortOK {
|
||||||
|
sort = sortWhitelist[0]
|
||||||
|
}
|
||||||
|
|
||||||
currentUser, err := session.CurrentUser(r)
|
currentUser, err := session.CurrentUser(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't get your current user: %s", err)
|
session.FlashError(w, r, "Couldn't get your current user: %s", err)
|
||||||
|
@ -44,32 +69,50 @@ func Feedback() http.HandlerFunc {
|
||||||
|
|
||||||
// Are we visiting a linked resource (via TableID)?
|
// Are we visiting a linked resource (via TableID)?
|
||||||
if fb != nil && fb.TableID > 0 && visit {
|
if fb != nil && fb.TableID > 0 && visit {
|
||||||
|
// New (Oct 17 '24): feedbacks may carry an AboutUserID, e.g. for photos in case the reported
|
||||||
|
// photo is removed then the associated owner of the photo is still carried in the report.
|
||||||
|
var aboutUser *models.User
|
||||||
|
if fb.AboutUserID > 0 {
|
||||||
|
if user, err := models.GetUser(fb.AboutUserID); err == nil {
|
||||||
|
aboutUser = user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch fb.TableName {
|
switch fb.TableName {
|
||||||
case "users":
|
case "users":
|
||||||
user, err := models.GetUser(fb.TableID)
|
user, err := models.GetUser(fb.TableID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
|
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
|
||||||
} else {
|
} else {
|
||||||
// If this is an "inner circle removal" report, go to their gallery and filter pics by Public.
|
templates.Redirect(w, "/u/"+user.Username)
|
||||||
if fb.Intent == "report.circle" {
|
|
||||||
templates.Redirect(w, "/photo/u/"+user.Username+"?visibility=public")
|
|
||||||
} else {
|
|
||||||
templates.Redirect(w, "/u/"+user.Username)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "photos":
|
case "photos":
|
||||||
pic, err := models.GetPhoto(fb.TableID)
|
pic, err := models.GetPhoto(fb.TableID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// If there was an About User, visit their profile page instead.
|
||||||
|
if aboutUser != nil {
|
||||||
|
session.FlashError(w, r, "The photo #%d was deleted, visiting the owner's profile page instead.", fb.TableID)
|
||||||
|
templates.Redirect(w, "/u/"+aboutUser.Username)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
session.FlashError(w, r, "Couldn't get photo %d: %s", fb.TableID, err)
|
session.FlashError(w, r, "Couldn't get photo %d: %s", fb.TableID, err)
|
||||||
} else {
|
} else {
|
||||||
// Going to the user's profile page?
|
// Going to the user's profile page?
|
||||||
if profile {
|
if profile {
|
||||||
user, err := models.GetUser(pic.UserID)
|
|
||||||
if err != nil {
|
// Going forward: the aboutUser will be populated, this is for legacy reports.
|
||||||
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
|
if aboutUser == nil {
|
||||||
} else {
|
if user, err := models.GetUser(pic.UserID); err == nil {
|
||||||
templates.Redirect(w, "/u/"+user.Username)
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -107,6 +150,15 @@ func Feedback() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "forums":
|
||||||
|
// Get this forum.
|
||||||
|
forum, err := models.GetForum(fb.TableID)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't get comment ID %d: %s", fb.TableID, err)
|
||||||
|
} else {
|
||||||
|
templates.Redirect(w, fmt.Sprintf("/f/%s", forum.Fragment))
|
||||||
|
return
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
session.FlashError(w, r, "Couldn't visit TableID %s/%d: not a supported TableName", fb.TableName, fb.TableID)
|
session.FlashError(w, r, "Couldn't visit TableID %s/%d: not a supported TableName", fb.TableName, fb.TableID)
|
||||||
}
|
}
|
||||||
|
@ -145,31 +197,51 @@ func Feedback() http.HandlerFunc {
|
||||||
pager := &models.Pagination{
|
pager := &models.Pagination{
|
||||||
Page: 1,
|
Page: 1,
|
||||||
PerPage: config.PageSizeAdminFeedback,
|
PerPage: config.PageSizeAdminFeedback,
|
||||||
Sort: "updated_at desc",
|
Sort: sort,
|
||||||
}
|
}
|
||||||
pager.ParsePage(r)
|
pager.ParsePage(r)
|
||||||
page, err := models.PaginateFeedback(acknowledged, intent, pager)
|
page, err := models.PaginateFeedback(acknowledged, intent, subject, search, pager)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't load feedback from DB: %s", err)
|
session.FlashError(w, r, "Couldn't load feedback from DB: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map user IDs.
|
// Map user IDs.
|
||||||
var userIDs = []uint64{}
|
var (
|
||||||
|
userIDs = []uint64{}
|
||||||
|
photoIDs = []uint64{}
|
||||||
|
)
|
||||||
for _, p := range page {
|
for _, p := range page {
|
||||||
if p.UserID > 0 {
|
if p.UserID > 0 {
|
||||||
userIDs = append(userIDs, p.UserID)
|
userIDs = append(userIDs, p.UserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if p.TableName == "photos" && p.TableID > 0 {
|
||||||
|
photoIDs = append(photoIDs, p.TableID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
userMap, err := models.MapUsers(currentUser, userIDs)
|
userMap, err := models.MapUsers(currentUser, userIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't map user IDs: %s", err)
|
session.FlashError(w, r, "Couldn't map user IDs: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map photo IDs.
|
||||||
|
photoMap, err := models.MapPhotos(photoIDs)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't map photo IDs: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
|
// Filter settings.
|
||||||
|
"DistinctSubjects": models.DistinctFeedbackSubjects(),
|
||||||
|
"SearchTerm": searchQuery,
|
||||||
|
"Subject": subject,
|
||||||
|
"Sort": sort,
|
||||||
|
|
||||||
"Intent": intent,
|
"Intent": intent,
|
||||||
"Acknowledged": acknowledged,
|
"Acknowledged": acknowledged,
|
||||||
"Feedback": page,
|
"Feedback": page,
|
||||||
"UserMap": userMap,
|
"UserMap": userMap,
|
||||||
|
"PhotoMap": photoMap,
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
|
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
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models/deletion"
|
"code.nonshy.com/nonshy/website/pkg/models/deletion"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
|
@ -24,6 +27,14 @@ func MarkPhotoExplicit() http.HandlerFunc {
|
||||||
next = "/"
|
next = "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get current user.
|
||||||
|
currentUser, err := session.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Failed to get current user: %s", err)
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if idInt, err := strconv.Atoi(r.FormValue("photo_id")); err == nil {
|
if idInt, err := strconv.Atoi(r.FormValue("photo_id")); err == nil {
|
||||||
photoID = uint64(idInt)
|
photoID = uint64(idInt)
|
||||||
} else {
|
} else {
|
||||||
|
@ -41,11 +52,18 @@ func MarkPhotoExplicit() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
photo.Explicit = true
|
photo.Explicit = true
|
||||||
|
photo.Flagged = true
|
||||||
if err := photo.Save(); err != nil {
|
if err := photo.Save(); err != nil {
|
||||||
session.FlashError(w, r, "Couldn't save photo: %s", err)
|
session.FlashError(w, r, "Couldn't save photo: %s", err)
|
||||||
} else {
|
} else {
|
||||||
session.Flash(w, r, "Marked photo as Explicit!")
|
session.Flash(w, r, "Marked photo as Explicit!")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogUpdated(&models.User{ID: photo.UserID}, currentUser, "photos", photo.ID, "Marked explicit by admin action.", []models.FieldDiff{
|
||||||
|
models.NewFieldDiff("Explicit", false, true),
|
||||||
|
})
|
||||||
|
|
||||||
templates.Redirect(w, next)
|
templates.Redirect(w, next)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -100,11 +118,88 @@ func UserActions() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get their block lists.
|
||||||
insights, err := models.GetBlocklistInsights(user)
|
insights, err := models.GetBlocklistInsights(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Error getting blocklist insights: %s", err)
|
session.FlashError(w, r, "Error getting blocklist insights: %s", err)
|
||||||
}
|
}
|
||||||
vars["BlocklistInsights"] = insights
|
vars["BlocklistInsights"] = insights
|
||||||
|
|
||||||
|
// Also surface counts of admin blocks.
|
||||||
|
count, total := models.CountBlockedAdminUsers(user)
|
||||||
|
vars["AdminBlockCount"] = count
|
||||||
|
vars["AdminBlockTotal"] = total
|
||||||
|
case "chat.rules":
|
||||||
|
// Chat Moderation Rules.
|
||||||
|
if !currentUser.HasAdminScope(config.ScopeChatModerator) {
|
||||||
|
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeChatModerator)
|
||||||
|
templates.Redirect(w, "/admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
// Rules list for the change log.
|
||||||
|
var newRules = "(none)"
|
||||||
|
if rule, ok := r.PostForm["rules"]; ok && len(rule) > 0 {
|
||||||
|
newRules = strings.Join(rule, ",")
|
||||||
|
user.SetProfileField("chat_moderation_rules", newRules)
|
||||||
|
if err := user.Save(); err != nil {
|
||||||
|
session.FlashError(w, r, "Error saving the user's chat rules: %s", err)
|
||||||
|
} else {
|
||||||
|
session.Flash(w, r, "Chat moderation rules have been updated!")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user.DeleteProfileField("chat_moderation_rules")
|
||||||
|
session.Flash(w, r, "All chat moderation rules have been cleared for user: %s", user.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
templates.Redirect(w, "/u/"+user.Username)
|
||||||
|
|
||||||
|
// Log the new rules to the changelog.
|
||||||
|
models.LogEvent(
|
||||||
|
user,
|
||||||
|
currentUser,
|
||||||
|
"updated",
|
||||||
|
"chat.rules",
|
||||||
|
user.ID,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"An admin has updated the chat moderation rules for this user.\n\n"+
|
||||||
|
"The update rules are: %s",
|
||||||
|
newRules,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vars["ChatModerationRules"] = config.ChatModerationRules
|
||||||
|
case "essays":
|
||||||
|
// Edit their profile essays easily.
|
||||||
|
if !currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||||
|
session.FlashError(w, r, "Missing admin scope: %s", config.ScopePhotoModerator)
|
||||||
|
templates.Redirect(w, "/admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
var (
|
||||||
|
about = r.PostFormValue("about_me")
|
||||||
|
interests = r.PostFormValue("interests")
|
||||||
|
musicMovies = r.PostFormValue("music_movies")
|
||||||
|
)
|
||||||
|
|
||||||
|
user.SetProfileField("about_me", about)
|
||||||
|
user.SetProfileField("interests", interests)
|
||||||
|
user.SetProfileField("music_movies", musicMovies)
|
||||||
|
|
||||||
|
if err := user.Save(); err != nil {
|
||||||
|
session.FlashError(w, r, "Error saving the user: %s", err)
|
||||||
|
} else {
|
||||||
|
session.Flash(w, r, "Their profile text has been updated!")
|
||||||
|
}
|
||||||
|
|
||||||
|
templates.Redirect(w, "/u/"+user.Username)
|
||||||
|
return
|
||||||
|
}
|
||||||
case "impersonate":
|
case "impersonate":
|
||||||
// Scope check.
|
// Scope check.
|
||||||
if !currentUser.HasAdminScope(config.ScopeUserImpersonate) {
|
if !currentUser.HasAdminScope(config.ScopeUserImpersonate) {
|
||||||
|
@ -141,6 +236,14 @@ func UserActions() http.HandlerFunc {
|
||||||
user.Save()
|
user.Save()
|
||||||
session.Flash(w, r, "User ban status updated!")
|
session.Flash(w, r, "User ban status updated!")
|
||||||
templates.Redirect(w, "/u/"+user.Username)
|
templates.Redirect(w, "/u/"+user.Username)
|
||||||
|
|
||||||
|
// Maybe kick them from chat room now.
|
||||||
|
if _, err := chat.MaybeDisconnectUser(user); err != nil {
|
||||||
|
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogEvent(user, currentUser, models.ChangeLogBanned, "users", currentUser.ID, fmt.Sprintf("User ban status updated to: %s", status))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "promote":
|
case "promote":
|
||||||
|
@ -156,6 +259,34 @@ func UserActions() http.HandlerFunc {
|
||||||
user.IsAdmin = action == "promote"
|
user.IsAdmin = action == "promote"
|
||||||
user.Save()
|
user.Save()
|
||||||
session.Flash(w, r, "User admin status updated!")
|
session.Flash(w, r, "User admin status updated!")
|
||||||
|
templates.Redirect(w, "/u/"+user.Username)
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogEvent(user, currentUser, models.ChangeLogAdmin, "users", currentUser.ID, fmt.Sprintf("User admin status updated to: %s", action))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "password":
|
||||||
|
// Scope check.
|
||||||
|
if !currentUser.HasAdminScope(config.ScopeUserPassword) {
|
||||||
|
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeUserPassword)
|
||||||
|
templates.Redirect(w, "/admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if confirm {
|
||||||
|
password := r.PostFormValue("password")
|
||||||
|
if len(password) < 3 {
|
||||||
|
session.FlashError(w, r, "A password of at least 3 characters is required.")
|
||||||
|
templates.Redirect(w, r.URL.Path+fmt.Sprintf("?intent=password&user_id=%d", user.ID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := user.SaveNewPassword(password); err != nil {
|
||||||
|
session.FlashError(w, r, "Failed to set the user's password: %s", err)
|
||||||
|
} else {
|
||||||
|
session.Flash(w, r, "The user's password has been updated to: %s", password)
|
||||||
|
}
|
||||||
|
|
||||||
templates.Redirect(w, "/u/"+user.Username)
|
templates.Redirect(w, "/u/"+user.Username)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -174,6 +305,14 @@ func UserActions() http.HandlerFunc {
|
||||||
session.Flash(w, r, "User has been deleted!")
|
session.Flash(w, r, "User has been deleted!")
|
||||||
}
|
}
|
||||||
templates.Redirect(w, "/admin")
|
templates.Redirect(w, "/admin")
|
||||||
|
|
||||||
|
// Kick them from the chat room if they are online.
|
||||||
|
if _, err := chat.DisconnectUserNow(user, "You have been signed out of chat because your account has been deleted."); err != nil {
|
||||||
|
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogDeleted(nil, currentUser, "users", user.ID, fmt.Sprintf("Username %s has been deleted by an admin.", user.Username), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|
|
@ -79,6 +79,9 @@ func Report() http.HandlerFunc {
|
||||||
|
|
||||||
log.Debug("Got chat report: %+v", report)
|
log.Debug("Got chat report: %+v", report)
|
||||||
|
|
||||||
|
// Make a clickable profile link for the channel ID (other user).
|
||||||
|
otherUsername := strings.TrimPrefix(report.Channel, "@")
|
||||||
|
|
||||||
// Create an admin Feedback model.
|
// Create an admin Feedback model.
|
||||||
fb := &models.Feedback{
|
fb := &models.Feedback{
|
||||||
Intent: "report",
|
Intent: "report",
|
||||||
|
@ -87,7 +90,7 @@ func Report() http.HandlerFunc {
|
||||||
"A message was reported on the chat room!\n\n"+
|
"A message was reported on the chat room!\n\n"+
|
||||||
"* From username: [%s](/u/%s)\n"+
|
"* From username: [%s](/u/%s)\n"+
|
||||||
"* About username: [%s](/u/%s)\n"+
|
"* About username: [%s](/u/%s)\n"+
|
||||||
"* Channel: **%s**\n"+
|
"* Channel: [**%s**](/u/%s)\n"+
|
||||||
"* Timestamp: %s\n"+
|
"* Timestamp: %s\n"+
|
||||||
"* Classification: %s\n"+
|
"* Classification: %s\n"+
|
||||||
"* User comment: %s\n\n"+
|
"* User comment: %s\n\n"+
|
||||||
|
@ -95,7 +98,7 @@ func Report() http.HandlerFunc {
|
||||||
"The reported message on chat was:\n\n%s",
|
"The reported message on chat was:\n\n%s",
|
||||||
report.FromUsername, report.FromUsername,
|
report.FromUsername, report.FromUsername,
|
||||||
report.AboutUsername, report.AboutUsername,
|
report.AboutUsername, report.AboutUsername,
|
||||||
report.Channel,
|
report.Channel, otherUsername,
|
||||||
report.Timestamp,
|
report.Timestamp,
|
||||||
report.Reason,
|
report.Reason,
|
||||||
report.Comment,
|
report.Comment,
|
||||||
|
@ -116,6 +119,7 @@ func Report() http.HandlerFunc {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
fb.TableName = "users"
|
fb.TableName = "users"
|
||||||
fb.TableID = targetUser.ID
|
fb.TableID = targetUser.ID
|
||||||
|
fb.AboutUserID = targetUser.ID
|
||||||
} else {
|
} else {
|
||||||
log.Error("BareRTC Chat Feedback: couldn't find user ID for AboutUsername=%s: %s", report.AboutUsername, err)
|
log.Error("BareRTC Chat Feedback: couldn't find user ID for AboutUsername=%s: %s", report.AboutUsername, err)
|
||||||
}
|
}
|
||||||
|
@ -193,12 +197,20 @@ func Profile() http.HandlerFunc {
|
||||||
|
|
||||||
var photoCount = models.CountPublicPhotos(currentUser.ID)
|
var photoCount = models.CountPublicPhotos(currentUser.ID)
|
||||||
|
|
||||||
|
// Member Since date.
|
||||||
|
var memberSinceDate = currentUser.CreatedAt
|
||||||
|
if currentUser.Certified {
|
||||||
|
if dt, err := currentUser.CertifiedSince(); err == nil {
|
||||||
|
memberSinceDate = dt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var resp = Response{
|
var resp = Response{
|
||||||
OK: true,
|
OK: true,
|
||||||
ProfileFields: []ProfileField{
|
ProfileFields: []ProfileField{
|
||||||
{
|
{
|
||||||
Name: "Member Since",
|
Name: "Certified since",
|
||||||
Value: fmt.Sprintf("%s ago", utility.FormatDurationCoarse(time.Since(currentUser.CreatedAt))),
|
Value: fmt.Sprintf("%s ago", utility.FormatDurationCoarse(time.Since(memberSinceDate))),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "📸 Gallery",
|
Name: "📸 Gallery",
|
||||||
|
|
|
@ -49,3 +49,17 @@ func SendJSON(w http.ResponseWriter, statusCode int, v interface{}) {
|
||||||
w.WriteHeader(statusCode)
|
w.WriteHeader(statusCode)
|
||||||
w.Write(buf)
|
w.Write(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendRawJSON response without the standard API wrapper.
|
||||||
|
func SendRawJSON(w http.ResponseWriter, statusCode int, v interface{}) {
|
||||||
|
buf, err := json.Marshal(v)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
w.Write(buf)
|
||||||
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/photo"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -96,25 +97,20 @@ func Likes() http.HandlerFunc {
|
||||||
case "photos":
|
case "photos":
|
||||||
if photo, err := models.GetPhoto(tableID); err == nil {
|
if photo, err := models.GetPhoto(tableID); err == nil {
|
||||||
if user, err := models.GetUser(photo.UserID); err == nil {
|
if user, err := models.GetUser(photo.UserID); err == nil {
|
||||||
// Admin safety check: in case the admin clicked 'Like' on a friends-only or private
|
// Safety check: if the current user should not see this picture, they can not "Like" it.
|
||||||
// picture they shouldn't have been expected to see, do not log a like.
|
// Example: you unfriended them but they still had the image on their old browser page.
|
||||||
if currentUser.IsAdmin && currentUser.ID != user.ID {
|
if ok, _ := photo.ShouldBeSeenBy(currentUser); !ok {
|
||||||
if (photo.Visibility == models.PhotoFriends && !models.AreFriends(user.ID, currentUser.ID)) ||
|
|
||||||
(photo.Visibility == models.PhotoPrivate && !models.IsPrivateUnlocked(user.ID, currentUser.ID)) {
|
|
||||||
SendJSON(w, http.StatusForbidden, Response{
|
|
||||||
Error: "You are not allowed to like that photo.",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blocking safety check: if either user blocks the other, liking is not allowed.
|
|
||||||
if models.IsBlocking(currentUser.ID, user.ID) {
|
|
||||||
SendJSON(w, http.StatusForbidden, Response{
|
SendJSON(w, http.StatusForbidden, Response{
|
||||||
Error: "You are not allowed to like that photo.",
|
Error: "You are not allowed to like that photo.",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark this photo as 'viewed' if it received a like.
|
||||||
|
// Example: on a gallery view the photo is only 'viewed' if interacted with (lightbox),
|
||||||
|
// going straight for the 'Like' button should count as well.
|
||||||
|
photo.View(currentUser)
|
||||||
|
|
||||||
targetUser = user
|
targetUser = user
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -124,7 +120,6 @@ func Likes() http.HandlerFunc {
|
||||||
log.Error("subject is users, find %d", tableID)
|
log.Error("subject is users, find %d", tableID)
|
||||||
if user, err := models.GetUser(tableID); err == nil {
|
if user, err := models.GetUser(tableID); err == nil {
|
||||||
targetUser = user
|
targetUser = user
|
||||||
log.Warn("found user %s", targetUser.Username)
|
|
||||||
|
|
||||||
// Blocking safety check: if either user blocks the other, liking is not allowed.
|
// Blocking safety check: if either user blocks the other, liking is not allowed.
|
||||||
if models.IsBlocking(currentUser.ID, user.ID) {
|
if models.IsBlocking(currentUser.ID, user.ID) {
|
||||||
|
@ -175,7 +170,7 @@ func Likes() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the target's notification about this like.
|
// 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 {
|
} else {
|
||||||
if err := models.AddLike(currentUser, req.TableName, tableID); err != nil {
|
if err := models.AddLike(currentUser, req.TableName, tableID); err != nil {
|
||||||
SendJSON(w, http.StatusBadRequest, Response{
|
SendJSON(w, http.StatusBadRequest, Response{
|
||||||
|
@ -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.
|
// Send success response.
|
||||||
SendJSON(w, http.StatusOK, Response{
|
SendJSON(w, http.StatusOK, Response{
|
||||||
OK: true,
|
OK: true,
|
||||||
|
@ -284,7 +286,7 @@ func WhoLikes() http.HandlerFunc {
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
result = append(result, Liker{
|
result = append(result, Liker{
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Avatar: user.VisibleAvatarURL(currentUser),
|
Avatar: photo.VisibleAvatarURL(user, currentUser),
|
||||||
Relationship: user.UserRelationship,
|
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)
|
session.FlashError(w, r, "Couldn't unblock this user: %s.", err)
|
||||||
} else {
|
} else {
|
||||||
session.Flash(w, r, "You have removed %s from your block list.", user.Username)
|
session.Flash(w, r, "You have removed %s from your block list.", user.Username)
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogDeleted(currentUser, nil, "blocks", user.ID, "Unblocked user "+user.Username+".", nil)
|
||||||
}
|
}
|
||||||
templates.Redirect(w, "/users/blocked")
|
templates.Redirect(w, "/users/blocked")
|
||||||
return
|
return
|
||||||
|
@ -109,17 +112,35 @@ func BlockUser() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Can't block admins who have the unblockable scope.
|
// If the target user is an admin, log this to the admin reports page.
|
||||||
if user.IsAdmin && user.HasAdminScope(config.ScopeUnblockable) {
|
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.
|
// For curiosity's sake, log a report.
|
||||||
fb := &models.Feedback{
|
fb := &models.Feedback{
|
||||||
Intent: "report",
|
Intent: "report",
|
||||||
Subject: "A user tried to block an admin",
|
Subject: "A user tried to block an admin",
|
||||||
Message: fmt.Sprintf(
|
Message: fmt.Sprintf(
|
||||||
"A user has tried to block an admin user account!\n\n"+
|
"A user has tried to block an admin user account!\n\n"+
|
||||||
"* Username: %s\n* Tried to block: %s",
|
"* Username: %s\n* Tried to block: %s\n\n%s",
|
||||||
currentUser.Username,
|
currentUser.Username,
|
||||||
user.Username,
|
user.Username,
|
||||||
|
footer,
|
||||||
),
|
),
|
||||||
UserID: currentUser.ID,
|
UserID: currentUser.ID,
|
||||||
TableName: "users",
|
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)
|
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.")
|
// If the admin is unblockable, give the user an error message and return.
|
||||||
templates.Redirect(w, "/u/"+username)
|
if unblockable {
|
||||||
return
|
session.FlashError(w, r, "You can not block site administrators.")
|
||||||
|
templates.Redirect(w, "/u/"+username)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block the target user.
|
// Block the target user.
|
||||||
|
@ -139,6 +163,9 @@ func BlockUser() http.HandlerFunc {
|
||||||
session.FlashError(w, r, "Couldn't block this user: %s.", err)
|
session.FlashError(w, r, "Couldn't block this user: %s.", err)
|
||||||
} else {
|
} else {
|
||||||
session.Flash(w, r, "You have added %s to your block list.", user.Username)
|
session.Flash(w, r, "You have added %s to your block list.", user.Username)
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogCreated(currentUser, "blocks", user.ID, "Blocks user "+user.Username+".")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync the block to the BareRTC chat server now, in case either user is currently online.
|
// Sync the block to the BareRTC chat server now, in case either user is currently online.
|
||||||
|
|
|
@ -3,7 +3,6 @@ package chat
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
@ -11,6 +10,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/encryption"
|
||||||
"code.nonshy.com/nonshy/website/pkg/geoip"
|
"code.nonshy.com/nonshy/website/pkg/geoip"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/middleware"
|
"code.nonshy.com/nonshy/website/pkg/middleware"
|
||||||
|
@ -22,16 +22,17 @@ import (
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JWT claims.
|
// Claims are the JWT claims for the BareRTC chat room.
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
// Custom claims.
|
// Custom claims.
|
||||||
IsAdmin bool `json:"op,omitempty"`
|
IsAdmin bool `json:"op,omitempty"`
|
||||||
VIP bool `json:"vip,omitempty"`
|
VIP bool `json:"vip,omitempty"`
|
||||||
Avatar string `json:"img,omitempty"`
|
Avatar string `json:"img,omitempty"`
|
||||||
ProfileURL string `json:"url,omitempty"`
|
ProfileURL string `json:"url,omitempty"`
|
||||||
Nickname string `json:"nick,omitempty"`
|
Nickname string `json:"nick,omitempty"`
|
||||||
Emoji string `json:"emoji,omitempty"`
|
Emoji string `json:"emoji,omitempty"`
|
||||||
Gender string `json:"gender,omitempty"`
|
Gender string `json:"gender,omitempty"`
|
||||||
|
Rules []string `json:"rules,omitempty"`
|
||||||
|
|
||||||
// Standard claims. Notes:
|
// Standard claims. Notes:
|
||||||
// subject = username
|
// subject = username
|
||||||
|
@ -76,16 +77,6 @@ func Landing() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we are shy, block chat for now.
|
|
||||||
if isShy {
|
|
||||||
session.FlashError(w, r,
|
|
||||||
"You have a Shy Account and are not allowed in the chat room at this time where our non-shy members may "+
|
|
||||||
"be on camera.",
|
|
||||||
)
|
|
||||||
templates.Redirect(w, "/chat")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get our Chat JWT secret.
|
// Get our Chat JWT secret.
|
||||||
var (
|
var (
|
||||||
secret = []byte(config.Current.BareRTC.JWTSecret)
|
secret = []byte(config.Current.BareRTC.JWTSecret)
|
||||||
|
@ -98,14 +89,12 @@ func Landing() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avatar URL - masked if non-public.
|
// Avatar URL - masked if non-public.
|
||||||
avatar := photo.URLPath(currentUser.ProfilePhoto.CroppedFilename)
|
avatar := photo.SignedPublicAvatarURL(currentUser.ProfilePhoto.CroppedFilename)
|
||||||
switch currentUser.ProfilePhoto.Visibility {
|
switch currentUser.ProfilePhoto.Visibility {
|
||||||
case models.PhotoPrivate:
|
case models.PhotoPrivate:
|
||||||
avatar = "/static/img/shy-private.png"
|
avatar = "/static/img/shy-private.png"
|
||||||
case models.PhotoFriends:
|
case models.PhotoFriends:
|
||||||
avatar = "/static/img/shy-friends.png"
|
avatar = "/static/img/shy-friends.png"
|
||||||
case models.PhotoInnerCircle:
|
|
||||||
avatar = "/static/img/shy-secret.png"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Country flag emoji.
|
// Country flag emoji.
|
||||||
|
@ -122,26 +111,29 @@ func Landing() http.HandlerFunc {
|
||||||
emoji = "🍰 It's my birthday!"
|
emoji = "🍰 It's my birthday!"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply chat moderation rules.
|
||||||
|
var rules = []string{}
|
||||||
|
if isShy {
|
||||||
|
// Shy account: no camera privileges.
|
||||||
|
rules = []string{"novideo", "noimage"}
|
||||||
|
} else if v := currentUser.GetProfileField("chat_moderation_rules"); len(v) > 0 {
|
||||||
|
// Specific mod rules applied to the current user.
|
||||||
|
rules = strings.Split(v, ",")
|
||||||
|
}
|
||||||
|
|
||||||
// Create the JWT claims.
|
// Create the JWT claims.
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator),
|
IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator),
|
||||||
VIP: currentUser.IsInnerCircle(),
|
Avatar: avatar,
|
||||||
Avatar: avatar,
|
ProfileURL: "/u/" + currentUser.Username,
|
||||||
ProfileURL: "/u/" + currentUser.Username,
|
Nickname: currentUser.NameOrUsername(),
|
||||||
Nickname: currentUser.NameOrUsername(),
|
Emoji: emoji,
|
||||||
Emoji: emoji,
|
Gender: Gender(currentUser),
|
||||||
Gender: Gender(currentUser),
|
VIP: isShy, // "shy accounts" use the "VIP" status for special icon in chat
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
Rules: rules,
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
|
RegisteredClaims: encryption.StandardClaims(currentUser.ID, currentUser.Username, time.Now().Add(5*time.Minute)),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
||||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
|
||||||
Issuer: config.Title,
|
|
||||||
Subject: currentUser.Username,
|
|
||||||
ID: fmt.Sprintf("%d", currentUser.ID),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token, err := encryption.SignClaims(claims, []byte(config.Current.BareRTC.JWTSecret))
|
||||||
ss, err := token.SignedString(secret)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't sign you into the chat: %s", err)
|
session.FlashError(w, r, "Couldn't sign you into the chat: %s", err)
|
||||||
templates.Redirect(w, r.URL.Path)
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
@ -153,8 +145,19 @@ func Landing() http.HandlerFunc {
|
||||||
log.Error("SendBlocklist: %s", err)
|
log.Error("SendBlocklist: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark them as online immediately: so e.g. on the Change Username screen we leave no window
|
||||||
|
// of time where they can exist in chat but change their name on the site.
|
||||||
|
worker.GetChatStatistics().SetOnlineNow(currentUser.Username)
|
||||||
|
|
||||||
|
// Ping their chat login usage statistic.
|
||||||
|
go func() {
|
||||||
|
if err := models.LogDailyChatUser(currentUser); err != nil {
|
||||||
|
log.Error("LogDailyChatUser(%s): error logging this user's chat statistic: %s", currentUser.Username, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// Redirect them to the chat room.
|
// Redirect them to the chat room.
|
||||||
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss)
|
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+token)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -117,7 +117,18 @@ func PostComment() http.HandlerFunc {
|
||||||
session.FlashError(w, r, "Error deleting your commenting: %s", err)
|
session.FlashError(w, r, "Error deleting your commenting: %s", err)
|
||||||
} else {
|
} else {
|
||||||
session.Flash(w, r, "Your comment has been deleted.")
|
session.Flash(w, r, "Your comment has been deleted.")
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogDeleted(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, "Deleted a comment.", comment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh cached like counts.
|
||||||
|
if tableName == "photos" {
|
||||||
|
if err := models.UpdatePhotoCachedCounts(tableID); err != nil {
|
||||||
|
log.Error("UpdatePhotoCachedCount(%d): %s", tableID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
templates.Redirect(w, fromURL)
|
templates.Redirect(w, fromURL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -151,6 +162,9 @@ func PostComment() http.HandlerFunc {
|
||||||
session.FlashError(w, r, "Couldn't save comment: %s", err)
|
session.FlashError(w, r, "Couldn't save comment: %s", err)
|
||||||
} else {
|
} else {
|
||||||
session.Flash(w, r, "Comment updated!")
|
session.Flash(w, r, "Comment updated!")
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogUpdated(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, "Updated a comment.\n\n---\n\n"+comment.Message, nil)
|
||||||
}
|
}
|
||||||
templates.Redirect(w, fromURL)
|
templates.Redirect(w, fromURL)
|
||||||
return
|
return
|
||||||
|
@ -168,6 +182,16 @@ func PostComment() http.HandlerFunc {
|
||||||
session.Flash(w, r, "Comment added!")
|
session.Flash(w, r, "Comment added!")
|
||||||
templates.Redirect(w, fromURL)
|
templates.Redirect(w, fromURL)
|
||||||
|
|
||||||
|
// Refresh cached comment counts.
|
||||||
|
if tableName == "photos" {
|
||||||
|
if err := models.UpdatePhotoCachedCounts(tableID); err != nil {
|
||||||
|
log.Error("UpdatePhotoCachedCount(%d): %s", tableID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogCreated(currentUser, "comments", comment.ID, "Posted a new comment.\n\n---\n\n"+message)
|
||||||
|
|
||||||
// Notify the recipient of the comment.
|
// Notify the recipient of the comment.
|
||||||
if notifyUser != nil && notifyUser.ID != currentUser.ID && !notifyUser.NotificationOptOut(config.NotificationOptOutComments) {
|
if notifyUser != nil && notifyUser.ID != currentUser.ID && !notifyUser.NotificationOptOut(config.NotificationOptOutComments) {
|
||||||
notif := &models.Notification{
|
notif := &models.Notification{
|
||||||
|
|
|
@ -28,12 +28,26 @@ func Subscription() http.HandlerFunc {
|
||||||
templates.Redirect(w, "/")
|
templates.Redirect(w, "/")
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
if idInt, err := strconv.Atoi(idStr); err != nil {
|
// Is the table_id expected to be a username?
|
||||||
session.FlashError(w, r, "Comment table ID invalid.")
|
switch tableName {
|
||||||
templates.Redirect(w, "/")
|
case "friend.photos":
|
||||||
return
|
// Special "Friend uploaded a new photo" opt-out.
|
||||||
} else {
|
if user, err := models.FindUser(idStr); err != nil {
|
||||||
tableID = uint64(idInt)
|
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.
|
// 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.")
|
session.FlashError(w, r, "You can not comment on that.")
|
||||||
templates.Redirect(w, "/")
|
templates.Redirect(w, "/")
|
||||||
return
|
return
|
||||||
|
@ -61,6 +75,12 @@ func Subscription() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Language to use in the flash messages.
|
||||||
|
var kind = "comments"
|
||||||
|
if tableName == "friend.photos" {
|
||||||
|
kind = "new photo uploads"
|
||||||
|
}
|
||||||
|
|
||||||
// Get their subscription.
|
// Get their subscription.
|
||||||
sub, err := models.GetSubscription(currentUser, tableName, tableID)
|
sub, err := models.GetSubscription(currentUser, tableName, tableID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -69,7 +89,15 @@ func Subscription() http.HandlerFunc {
|
||||||
if _, err := models.SubscribeTo(currentUser, tableName, tableID); err != nil {
|
if _, err := models.SubscribeTo(currentUser, tableName, tableID); err != nil {
|
||||||
session.FlashError(w, r, "Couldn't create subscription: %s", err)
|
session.FlashError(w, r, "Couldn't create subscription: %s", err)
|
||||||
} else {
|
} else {
|
||||||
session.Flash(w, r, "You will now be notified about 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 {
|
} else {
|
||||||
|
@ -79,9 +107,9 @@ func Subscription() http.HandlerFunc {
|
||||||
session.FlashError(w, r, "Couldn't save your subscription settings: %s", err)
|
session.FlashError(w, r, "Couldn't save your subscription settings: %s", err)
|
||||||
} else {
|
} else {
|
||||||
if subscribe {
|
if subscribe {
|
||||||
session.Flash(w, r, "You will now be notified about comments on this page.")
|
session.Flash(w, r, "You will now be notified about %s on this page.", kind)
|
||||||
} else {
|
} 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
|
package forum
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -44,7 +45,7 @@ func AddEdit() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
// Do we have permission?
|
// Do we have permission?
|
||||||
if found.OwnerID != currentUser.ID && !currentUser.IsAdmin {
|
if !found.CanEdit(currentUser) {
|
||||||
templates.ForbiddenPage(w, r)
|
templates.ForbiddenPage(w, r)
|
||||||
return
|
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?
|
// Saving?
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
var (
|
var (
|
||||||
|
@ -63,29 +71,49 @@ func AddEdit() http.HandlerFunc {
|
||||||
isExplicit = r.PostFormValue("explicit") == "true"
|
isExplicit = r.PostFormValue("explicit") == "true"
|
||||||
isPrivileged = r.PostFormValue("privileged") == "true"
|
isPrivileged = r.PostFormValue("privileged") == "true"
|
||||||
isPermitPhotos = r.PostFormValue("permit_photos") == "true"
|
isPermitPhotos = r.PostFormValue("permit_photos") == "true"
|
||||||
isInnerCircle = r.PostFormValue("inner_circle") == "true"
|
isPrivate = r.PostFormValue("private") == "true"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sanity check admin-only settings.
|
// Sanity check admin-only settings -> default these to OFF.
|
||||||
if !currentUser.IsAdmin {
|
if !currentUser.HasAdminScope(config.ScopeForumAdmin) {
|
||||||
isPrivileged = false
|
isPrivileged = false
|
||||||
isPermitPhotos = false
|
isPrivate = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Were we editing an existing forum?
|
// Were we editing an existing forum?
|
||||||
if forum != nil {
|
if forum != nil {
|
||||||
|
diffs := []models.FieldDiff{
|
||||||
|
models.NewFieldDiff("Title", forum.Title, title),
|
||||||
|
models.NewFieldDiff("Description", forum.Description, description),
|
||||||
|
models.NewFieldDiff("Category", forum.Category, category),
|
||||||
|
models.NewFieldDiff("Explicit", forum.Explicit, isExplicit),
|
||||||
|
models.NewFieldDiff("PermitPhotos", forum.PermitPhotos, isPermitPhotos),
|
||||||
|
}
|
||||||
|
|
||||||
forum.Title = title
|
forum.Title = title
|
||||||
forum.Description = description
|
forum.Description = description
|
||||||
forum.Category = category
|
forum.Category = category
|
||||||
forum.Explicit = isExplicit
|
forum.Explicit = isExplicit
|
||||||
forum.Privileged = isPrivileged
|
|
||||||
forum.PermitPhotos = isPermitPhotos
|
forum.PermitPhotos = isPermitPhotos
|
||||||
forum.InnerCircle = isInnerCircle
|
|
||||||
|
// Forum Admin-only options: if the current viewer is not a forum admin, do not change these settings.
|
||||||
|
// e.g.: the front-end checkboxes are hidden and don't want to accidentally unset these!
|
||||||
|
if currentUser.HasAdminScope(config.ScopeForumAdmin) {
|
||||||
|
diffs = append(diffs,
|
||||||
|
models.NewFieldDiff("Privileged", forum.Privileged, isPrivileged),
|
||||||
|
models.NewFieldDiff("Private", forum.Private, isPrivate),
|
||||||
|
)
|
||||||
|
forum.Privileged = isPrivileged
|
||||||
|
forum.Private = isPrivate
|
||||||
|
}
|
||||||
|
|
||||||
// Save it.
|
// Save it.
|
||||||
if err := forum.Save(); err == nil {
|
if err := forum.Save(); err == nil {
|
||||||
session.Flash(w, r, "Forum has been updated!")
|
session.Flash(w, r, "Forum has been updated!")
|
||||||
templates.Redirect(w, "/forum/admin")
|
templates.Redirect(w, "/forum/admin")
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogUpdated(currentUser, nil, "forums", forum.ID, "Updated the forum's settings.", diffs)
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
session.FlashError(w, r, "Error saving the forum: %s", err)
|
session.FlashError(w, r, "Error saving the forum: %s", err)
|
||||||
|
@ -113,12 +141,39 @@ func AddEdit() http.HandlerFunc {
|
||||||
Explicit: isExplicit,
|
Explicit: isExplicit,
|
||||||
Privileged: isPrivileged,
|
Privileged: isPrivileged,
|
||||||
PermitPhotos: isPermitPhotos,
|
PermitPhotos: isPermitPhotos,
|
||||||
InnerCircle: isInnerCircle,
|
Private: isPrivate,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := models.CreateForum(forum); err == nil {
|
if err := models.CreateForum(forum); err == nil {
|
||||||
session.Flash(w, r, "The forum has been created!")
|
session.Flash(w, r, "The forum has been created!")
|
||||||
templates.Redirect(w, "/forum/admin")
|
templates.Redirect(w, "/forum/admin")
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogCreated(currentUser, "forums", forum.ID, fmt.Sprintf(
|
||||||
|
"Created a new forum.\n\n"+
|
||||||
|
"* Category: %s\n"+
|
||||||
|
"* Title: %s\n"+
|
||||||
|
"* Fragment: %s\n"+
|
||||||
|
"* Description: %s\n"+
|
||||||
|
"* Explicit: %v\n"+
|
||||||
|
"* Privileged: %v\n"+
|
||||||
|
"* Photos: %v\n"+
|
||||||
|
"* Private: %v",
|
||||||
|
forum.Category,
|
||||||
|
forum.Title,
|
||||||
|
forum.Fragment,
|
||||||
|
forum.Description,
|
||||||
|
forum.Explicit,
|
||||||
|
forum.Privileged,
|
||||||
|
forum.PermitPhotos,
|
||||||
|
forum.Private,
|
||||||
|
))
|
||||||
|
|
||||||
|
// If this is a Community forum, subscribe the owner to it immediately.
|
||||||
|
if forum.Category == "" {
|
||||||
|
models.CreateForumMembership(currentUser, forum)
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
session.FlashError(w, r, "Error creating the forum: %s", err)
|
session.FlashError(w, r, "Error creating the forum: %s", err)
|
||||||
|
@ -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{}{
|
var vars = map[string]interface{}{
|
||||||
"EditID": editID,
|
"EditID": editID,
|
||||||
"EditForum": forum,
|
"EditForum": forum,
|
||||||
"Categories": config.ForumCategories,
|
"Categories": config.ForumCategories,
|
||||||
|
"Moderators": mods,
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Parse the path parameters
|
// Parse the path parameters
|
||||||
var (
|
var (
|
||||||
forum *models.Forum
|
fragment = r.PathValue("fragment")
|
||||||
|
forum *models.Forum
|
||||||
)
|
)
|
||||||
|
|
||||||
if m := ForumPathRegexp.FindStringSubmatch(r.URL.Path); m == nil {
|
// Look up the forum by its fragment.
|
||||||
log.Error("Regexp failed to parse: %s", r.URL.Path)
|
if found, err := models.ForumByFragment(fragment); err != nil {
|
||||||
templates.NotFoundPage(w, r)
|
templates.NotFoundPage(w, r)
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
// Look up the forum itself.
|
forum = found
|
||||||
if found, err := models.ForumByFragment(m[1]); err != nil {
|
|
||||||
templates.NotFoundPage(w, r)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
forum = found
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the current user.
|
// Get the current user.
|
||||||
|
@ -41,8 +36,8 @@ func Forum() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Is it an inner circle forum?
|
// Is it a private forum?
|
||||||
if forum.InnerCircle && !currentUser.IsInnerCircle() {
|
if !forum.CanBeSeenBy(currentUser) {
|
||||||
templates.NotFoundPage(w, r)
|
templates.NotFoundPage(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -60,7 +55,7 @@ func Forum() http.HandlerFunc {
|
||||||
var pager = &models.Pagination{
|
var pager = &models.Pagination{
|
||||||
Page: 1,
|
Page: 1,
|
||||||
PerPage: config.PageSizeThreadList,
|
PerPage: config.PageSizeThreadList,
|
||||||
Sort: "updated_at desc",
|
Sort: "threads.updated_at desc",
|
||||||
}
|
}
|
||||||
pager.ParsePage(r)
|
pager.ParsePage(r)
|
||||||
|
|
||||||
|
@ -71,17 +66,28 @@ func Forum() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject pinned threads on top.
|
// Inject pinned threads on top of the first page.
|
||||||
threads = append(pinned, threads...)
|
if pager.Page == 1 {
|
||||||
|
threads = append(pinned, threads...)
|
||||||
|
}
|
||||||
|
|
||||||
// Map the statistics (replies, views) of these threads.
|
// Map the statistics (replies, views) of these threads.
|
||||||
threadMap := models.MapThreadStatistics(threads)
|
threadMap := models.MapThreadStatistics(threads)
|
||||||
|
|
||||||
|
// Load the forum's moderators.
|
||||||
|
mods, err := forum.GetModerators()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Getting forum moderators: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"Forum": forum,
|
"Forum": forum,
|
||||||
"Threads": threads,
|
"ForumModerators": mods,
|
||||||
"ThreadMap": threadMap,
|
"ForumSubscriberCount": models.CountForumMemberships(forum),
|
||||||
"Pager": pager,
|
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
|
||||||
|
"Threads": threads,
|
||||||
|
"ThreadMap": threadMap,
|
||||||
|
"Pager": pager,
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
|
@ -17,17 +17,6 @@ var (
|
||||||
FragmentRegexp = regexp.MustCompile(
|
FragmentRegexp = regexp.MustCompile(
|
||||||
fmt.Sprintf(`^(%s)$`, FragmentPattern),
|
fmt.Sprintf(`^(%s)$`, FragmentPattern),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Forum path parameters.
|
|
||||||
ForumPathRegexp = regexp.MustCompile(
|
|
||||||
fmt.Sprintf(`^/f/(%s)`, FragmentPattern),
|
|
||||||
)
|
|
||||||
ForumPostRegexp = regexp.MustCompile(
|
|
||||||
fmt.Sprintf(`^/f/(%s)/(post)`, FragmentPattern),
|
|
||||||
)
|
|
||||||
ForumThreadRegexp = regexp.MustCompile(
|
|
||||||
fmt.Sprintf(`^/f/(%s)/(thread)/(\d+)`, FragmentPattern),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Landing page for forums.
|
// Landing page for forums.
|
||||||
|
@ -44,14 +33,13 @@ func Landing() http.HandlerFunc {
|
||||||
|
|
||||||
// Get all the categorized index forums.
|
// Get all the categorized index forums.
|
||||||
// XXX: we get a large page size to get ALL official forums
|
// XXX: we get a large page size to get ALL official forums
|
||||||
var pager = &models.Pagination{
|
// This pager is hardcoded and doesn't parse from ?page= params.
|
||||||
|
var indexPager = &models.Pagination{
|
||||||
Page: 1,
|
Page: 1,
|
||||||
PerPage: config.PageSizeForums,
|
PerPage: config.PageSizeForums,
|
||||||
Sort: "title asc",
|
Sort: "title asc",
|
||||||
}
|
}
|
||||||
pager.ParsePage(r)
|
forums, err := models.PaginateForums(currentUser, config.ForumCategories, nil, false, indexPager)
|
||||||
|
|
||||||
forums, err := models.PaginateForums(currentUser, config.ForumCategories, pager)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
|
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
|
||||||
templates.Redirect(w, "/")
|
templates.Redirect(w, "/")
|
||||||
|
@ -61,13 +49,41 @@ func Landing() http.HandlerFunc {
|
||||||
// Bucket the forums into their categories for easy front-end.
|
// Bucket the forums into their categories for easy front-end.
|
||||||
categorized := models.CategorizeForums(forums, config.ForumCategories)
|
categorized := models.CategorizeForums(forums, config.ForumCategories)
|
||||||
|
|
||||||
|
// Inject the "My List" Category if the user subscribes to forums.
|
||||||
|
var pager = &models.Pagination{
|
||||||
|
Page: 1,
|
||||||
|
PerPage: config.PageSizeMyListForums,
|
||||||
|
Sort: "by_latest",
|
||||||
|
}
|
||||||
|
pager.ParsePage(r)
|
||||||
|
if config.UserForumsEnabled {
|
||||||
|
myList, err := models.PaginateForums(currentUser, nil, nil, true, pager)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't get your followed forums: %s", err)
|
||||||
|
} else if len(myList) > 0 {
|
||||||
|
forums = append(forums, myList...)
|
||||||
|
categorized = append([]*models.CategorizedForum{
|
||||||
|
{
|
||||||
|
Category: "My List",
|
||||||
|
Forums: myList,
|
||||||
|
},
|
||||||
|
}, categorized...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Map statistics for these forums.
|
// Map statistics for these forums.
|
||||||
forumMap := models.MapForumStatistics(forums)
|
forumMap := models.MapForumStatistics(forums)
|
||||||
|
followMap := models.MapForumMemberships(currentUser, forums)
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
"Categories": categorized,
|
"Categories": categorized,
|
||||||
"ForumMap": forumMap,
|
"ForumMap": forumMap,
|
||||||
|
"FollowMap": followMap,
|
||||||
|
"FollowersMap": models.MapForumFollowers(forums),
|
||||||
|
|
||||||
|
// Current viewer's forum quota.
|
||||||
|
"ForumQuota": models.ComputeForumQuota(currentUser),
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
|
@ -12,7 +12,40 @@ import (
|
||||||
// Manage page for forums -- admin only for now but may open up later.
|
// Manage page for forums -- admin only for now but may open up later.
|
||||||
func Manage() http.HandlerFunc {
|
func Manage() http.HandlerFunc {
|
||||||
tmpl := templates.Must("forum/admin.html")
|
tmpl := templates.Must("forum/admin.html")
|
||||||
|
|
||||||
|
// Whitelist for ordering options.
|
||||||
|
var sortWhitelist = []string{
|
||||||
|
"updated_at desc",
|
||||||
|
"created_at desc",
|
||||||
|
}
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
searchTerm = r.FormValue("q")
|
||||||
|
show = r.FormValue("show")
|
||||||
|
categories = []string{}
|
||||||
|
sort = r.FormValue("sort")
|
||||||
|
sortOK bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sort options.
|
||||||
|
for _, v := range sortWhitelist {
|
||||||
|
if sort == v {
|
||||||
|
sortOK = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sortOK {
|
||||||
|
sort = sortWhitelist[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show options.
|
||||||
|
if show == "official" {
|
||||||
|
categories = config.ForumCategories
|
||||||
|
} else if show == "community" {
|
||||||
|
categories = []string{""}
|
||||||
|
}
|
||||||
|
|
||||||
// Get the current user.
|
// Get the current user.
|
||||||
currentUser, err := session.CurrentUser(r)
|
currentUser, err := session.CurrentUser(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -21,15 +54,24 @@ func Manage() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse their search term.
|
||||||
|
var search = models.ParseSearchString(searchTerm)
|
||||||
|
|
||||||
// Get forums the user owns or can manage.
|
// Get forums the user owns or can manage.
|
||||||
var pager = &models.Pagination{
|
var pager = &models.Pagination{
|
||||||
Page: 1,
|
Page: 1,
|
||||||
PerPage: config.PageSizeForumAdmin,
|
PerPage: config.PageSizeForumAdmin,
|
||||||
Sort: "updated_at desc",
|
Sort: sort,
|
||||||
}
|
}
|
||||||
pager.ParsePage(r)
|
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 {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't paginate owned forums: %s", err)
|
session.FlashError(w, r, "Couldn't paginate owned forums: %s", err)
|
||||||
templates.Redirect(w, "/")
|
templates.Redirect(w, "/")
|
||||||
|
@ -39,6 +81,15 @@ func Manage() http.HandlerFunc {
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
"Forums": forums,
|
"Forums": forums,
|
||||||
|
|
||||||
|
// Quote settings.
|
||||||
|
"QuotaLimit": models.ComputeForumQuota(currentUser),
|
||||||
|
"QuotaCount": models.CountOwnedUserForums(currentUser),
|
||||||
|
|
||||||
|
// Search filters.
|
||||||
|
"SearchTerm": searchTerm,
|
||||||
|
"Show": show,
|
||||||
|
"Sort": sort,
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
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/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/photo"
|
"code.nonshy.com/nonshy/website/pkg/photo"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/spam"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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?
|
// Does the comment have an existing Photo ID?
|
||||||
if len(photoID) > 0 {
|
if len(photoID) > 0 {
|
||||||
if i, err := strconv.Atoi(photoID); err == nil {
|
if i, err := strconv.Atoi(photoID); err == nil {
|
||||||
|
@ -115,7 +121,7 @@ func NewPost() http.HandlerFunc {
|
||||||
comment = found
|
comment = found
|
||||||
|
|
||||||
// Verify that it is indeed OUR comment.
|
// Verify that it is indeed OUR comment.
|
||||||
if currentUser.ID != comment.UserID && !currentUser.HasAdminScope(config.ScopeForumModerator) {
|
if currentUser.ID != comment.UserID && !canModerate {
|
||||||
templates.ForbiddenPage(w, r)
|
templates.ForbiddenPage(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -161,6 +167,11 @@ func NewPost() http.HandlerFunc {
|
||||||
session.FlashError(w, r, "Error deleting your post: %s", err)
|
session.FlashError(w, r, "Error deleting your post: %s", err)
|
||||||
} else {
|
} else {
|
||||||
session.Flash(w, r, "Your post has been deleted.")
|
session.Flash(w, r, "Your post has been deleted.")
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogDeleted(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, fmt.Sprintf(
|
||||||
|
"Deleted a forum comment on thread %d forum /f/%s", thread.ID, forum.Fragment,
|
||||||
|
), comment)
|
||||||
}
|
}
|
||||||
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
||||||
return
|
return
|
||||||
|
@ -178,6 +189,19 @@ func NewPost() http.HandlerFunc {
|
||||||
|
|
||||||
// Submitting the form.
|
// Submitting the form.
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
|
// Look for spammy links to video sites or things.
|
||||||
|
if err := spam.DetectSpamMessage(title + message); err != nil {
|
||||||
|
session.FlashError(w, r, err.Error())
|
||||||
|
if thread != nil {
|
||||||
|
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
||||||
|
} else if forum != nil {
|
||||||
|
templates.Redirect(w, fmt.Sprintf("/f/%s", forum.Fragment))
|
||||||
|
} else {
|
||||||
|
templates.Redirect(w, "/forum")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Polls: parse form parameters into a neat list of answers.
|
// Polls: parse form parameters into a neat list of answers.
|
||||||
pollExpires, _ = strconv.Atoi(r.FormValue("poll_expires"))
|
pollExpires, _ = strconv.Atoi(r.FormValue("poll_expires"))
|
||||||
var distinctPollChoices = map[string]interface{}{}
|
var distinctPollChoices = map[string]interface{}{}
|
||||||
|
@ -315,6 +339,14 @@ func NewPost() http.HandlerFunc {
|
||||||
session.FlashError(w, r, "Couldn't save comment: %s", err)
|
session.FlashError(w, r, "Couldn't save comment: %s", err)
|
||||||
} else {
|
} else {
|
||||||
session.Flash(w, r, "Comment updated!")
|
session.Flash(w, r, "Comment updated!")
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogUpdated(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, fmt.Sprintf(
|
||||||
|
"Edited their comment on thread %d (in /f/%s):\n\n%s",
|
||||||
|
thread.ID,
|
||||||
|
forum.Fragment,
|
||||||
|
message,
|
||||||
|
), nil)
|
||||||
}
|
}
|
||||||
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
||||||
return
|
return
|
||||||
|
@ -327,6 +359,13 @@ func NewPost() http.HandlerFunc {
|
||||||
} else {
|
} else {
|
||||||
session.Flash(w, r, "Reply added to the thread!")
|
session.Flash(w, r, "Reply added to the thread!")
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogCreated(currentUser, "comments", reply.ID, fmt.Sprintf(
|
||||||
|
"Commented on thread %d:\n\n%s",
|
||||||
|
thread.ID,
|
||||||
|
message,
|
||||||
|
))
|
||||||
|
|
||||||
// If we're attaching a photo, link it to this reply CommentID.
|
// If we're attaching a photo, link it to this reply CommentID.
|
||||||
if commentPhoto != nil {
|
if commentPhoto != nil {
|
||||||
commentPhoto.CommentID = reply.ID
|
commentPhoto.CommentID = reply.ID
|
||||||
|
@ -358,7 +397,7 @@ func NewPost() http.HandlerFunc {
|
||||||
TableName: "threads",
|
TableName: "threads",
|
||||||
TableID: thread.ID,
|
TableID: thread.ID,
|
||||||
Message: message,
|
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 {
|
if err := models.CreateNotification(notif); err != nil {
|
||||||
log.Error("Couldn't create thread reply notification for subscriber %d: %s", userID, err)
|
log.Error("Couldn't create thread reply notification for subscriber %d: %s", userID, err)
|
||||||
|
@ -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))
|
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,14 @@ import (
|
||||||
func Newest() http.HandlerFunc {
|
func Newest() http.HandlerFunc {
|
||||||
tmpl := templates.Must("forum/newest.html")
|
tmpl := templates.Must("forum/newest.html")
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Query parameters.
|
||||||
|
var (
|
||||||
|
allComments = r.FormValue("all") == "true"
|
||||||
|
whichForums = r.FormValue("which")
|
||||||
|
categories = []string{}
|
||||||
|
subscribed bool
|
||||||
|
)
|
||||||
|
|
||||||
// Get the current user.
|
// Get the current user.
|
||||||
currentUser, err := session.CurrentUser(r)
|
currentUser, err := session.CurrentUser(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -22,6 +30,29 @@ func Newest() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recall the user's default "Which forum:" answer if not selected.
|
||||||
|
if whichForums == "" {
|
||||||
|
whichForums = currentUser.GetProfileField("forum_newest_default")
|
||||||
|
if whichForums == "" {
|
||||||
|
whichForums = "official"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Narrow down to which set of forums?
|
||||||
|
switch whichForums {
|
||||||
|
case "official":
|
||||||
|
categories = config.ForumCategories
|
||||||
|
case "community":
|
||||||
|
categories = []string{""}
|
||||||
|
case "followed":
|
||||||
|
subscribed = true
|
||||||
|
default:
|
||||||
|
whichForums = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store their "Which forums" filter to be their new default view.
|
||||||
|
currentUser.SetProfileField("forum_newest_default", whichForums)
|
||||||
|
|
||||||
// Get all the categorized index forums.
|
// Get all the categorized index forums.
|
||||||
var pager = &models.Pagination{
|
var pager = &models.Pagination{
|
||||||
Page: 1,
|
Page: 1,
|
||||||
|
@ -29,7 +60,7 @@ func Newest() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
pager.ParsePage(r)
|
pager.ParsePage(r)
|
||||||
|
|
||||||
posts, err := models.PaginateRecentPosts(currentUser, config.ForumCategories, pager)
|
posts, err := models.PaginateRecentPosts(currentUser, categories, subscribed, allComments, pager)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
|
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
|
||||||
templates.Redirect(w, "/")
|
templates.Redirect(w, "/")
|
||||||
|
@ -47,9 +78,14 @@ func Newest() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"Pager": pager,
|
"CurrentForumTab": "newest",
|
||||||
"RecentPosts": posts,
|
"Pager": pager,
|
||||||
"PhotoMap": photos,
|
"RecentPosts": posts,
|
||||||
|
"PhotoMap": photos,
|
||||||
|
|
||||||
|
// Filter options.
|
||||||
|
"WhichForums": whichForums,
|
||||||
|
"AllComments": allComments,
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
|
@ -25,6 +25,8 @@ func Search() http.HandlerFunc {
|
||||||
searchTerm = r.FormValue("q")
|
searchTerm = r.FormValue("q")
|
||||||
byUsername = r.FormValue("username")
|
byUsername = r.FormValue("username")
|
||||||
postType = r.FormValue("type")
|
postType = r.FormValue("type")
|
||||||
|
inForum = r.FormValue("in")
|
||||||
|
categories = []string{}
|
||||||
sort = r.FormValue("sort")
|
sort = r.FormValue("sort")
|
||||||
sortOK bool
|
sortOK bool
|
||||||
)
|
)
|
||||||
|
@ -45,6 +47,14 @@ func Search() http.HandlerFunc {
|
||||||
postType = "all"
|
postType = "all"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In forums
|
||||||
|
switch inForum {
|
||||||
|
case "official":
|
||||||
|
categories = config.ForumCategories
|
||||||
|
case "community":
|
||||||
|
categories = []string{""}
|
||||||
|
}
|
||||||
|
|
||||||
// Get the current user.
|
// Get the current user.
|
||||||
currentUser, err := session.CurrentUser(r)
|
currentUser, err := session.CurrentUser(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -80,7 +90,7 @@ func Search() http.HandlerFunc {
|
||||||
)
|
)
|
||||||
pager.ParsePage(r)
|
pager.ParsePage(r)
|
||||||
|
|
||||||
posts, err := models.SearchForum(currentUser, search, filters, pager)
|
posts, err := models.SearchForum(currentUser, categories, search, filters, pager)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't search the forums: %s", err)
|
session.FlashError(w, r, "Couldn't search the forums: %s", err)
|
||||||
templates.Redirect(w, "/")
|
templates.Redirect(w, "/")
|
||||||
|
@ -100,14 +110,16 @@ func Search() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"Pager": pager,
|
"CurrentForumTab": "search",
|
||||||
"Comments": posts,
|
"Pager": pager,
|
||||||
"ThreadMap": threadMap,
|
"Comments": posts,
|
||||||
"PhotoMap": photos,
|
"ThreadMap": threadMap,
|
||||||
|
"PhotoMap": photos,
|
||||||
|
|
||||||
"SearchTerm": searchTerm,
|
"SearchTerm": searchTerm,
|
||||||
"ByUsername": byUsername,
|
"ByUsername": byUsername,
|
||||||
"Type": postType,
|
"Type": postType,
|
||||||
|
"InForum": inForum,
|
||||||
"Sort": sort,
|
"Sort": sort,
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
|
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 (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
@ -12,24 +11,22 @@ import (
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ThreadPathRegexp = regexp.MustCompile(`^/forum/thread/(\d+)$`)
|
|
||||||
|
|
||||||
// Thread view for the comment thread body of a forum post.
|
// Thread view for the comment thread body of a forum post.
|
||||||
func Thread() http.HandlerFunc {
|
func Thread() http.HandlerFunc {
|
||||||
tmpl := templates.Must("forum/thread.html")
|
tmpl := templates.Must("forum/thread.html")
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Parse the path parameters
|
// Parse the path parameters
|
||||||
var (
|
var (
|
||||||
|
idStr = r.PathValue("id")
|
||||||
forum *models.Forum
|
forum *models.Forum
|
||||||
thread *models.Thread
|
thread *models.Thread
|
||||||
)
|
)
|
||||||
|
|
||||||
if m := ThreadPathRegexp.FindStringSubmatch(r.URL.Path); m == nil {
|
if idStr == "" {
|
||||||
log.Error("Regexp failed to parse: %s", r.URL.Path)
|
|
||||||
templates.NotFoundPage(w, r)
|
templates.NotFoundPage(w, r)
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
if threadID, err := strconv.Atoi(m[1]); err != nil {
|
if threadID, err := strconv.Atoi(idStr); err != nil {
|
||||||
session.FlashError(w, r, "Invalid thread ID in the address bar.")
|
session.FlashError(w, r, "Invalid thread ID in the address bar.")
|
||||||
templates.Redirect(w, "/forum")
|
templates.Redirect(w, "/forum")
|
||||||
return
|
return
|
||||||
|
@ -54,12 +51,16 @@ func Thread() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Is it an inner circle forum?
|
// Is it a private forum?
|
||||||
if forum.InnerCircle && !currentUser.IsInnerCircle() {
|
if !forum.CanBeSeenBy(currentUser) {
|
||||||
templates.NotFoundPage(w, r)
|
templates.NotFoundPage(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Can we moderate this forum? (from a user-owned forum perspective,
|
||||||
|
// e.g. can we delete threads and posts, not edit them)
|
||||||
|
var canModerate = forum.CanBeModeratedBy(currentUser)
|
||||||
|
|
||||||
// Ping the view count on this thread.
|
// Ping the view count on this thread.
|
||||||
if err := thread.View(currentUser.ID); err != nil {
|
if err := thread.View(currentUser.ID); err != nil {
|
||||||
log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err)
|
log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err)
|
||||||
|
@ -73,7 +74,7 @@ func Thread() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
pager.ParsePage(r)
|
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 {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Couldn't paginate comments: %s", err)
|
session.FlashError(w, r, "Couldn't paginate comments: %s", err)
|
||||||
templates.Redirect(w, "/")
|
templates.Redirect(w, "/")
|
||||||
|
@ -96,14 +97,23 @@ func Thread() http.HandlerFunc {
|
||||||
// Is the current user subscribed to notifications on this thread?
|
// Is the current user subscribed to notifications on this thread?
|
||||||
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
|
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
|
||||||
|
|
||||||
|
// Ping this user as having used the forums today.
|
||||||
|
go func() {
|
||||||
|
if err := models.LogDailyForumUser(currentUser); err != nil {
|
||||||
|
log.Error("LogDailyForumUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"Forum": forum,
|
"Forum": forum,
|
||||||
"Thread": thread,
|
"Thread": thread,
|
||||||
"Comments": comments,
|
"Comments": comments,
|
||||||
"LikeMap": commentLikeMap,
|
"LikeMap": commentLikeMap,
|
||||||
"PhotoMap": photos,
|
"PhotoMap": photos,
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
"IsSubscribed": isSubscribed,
|
"CanModerate": canModerate,
|
||||||
|
"IsSubscribed": isSubscribed,
|
||||||
|
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/webpush"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddFriend controller to send a friend request.
|
// AddFriend controller to send a friend request.
|
||||||
|
@ -60,6 +61,11 @@ func AddFriend() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Revoke any friends-only photo notifications they had received before.
|
||||||
|
if err := models.RevokeFriendPhotoNotifications(currentUser, user); err != nil {
|
||||||
|
log.Error("Couldn't revoke friend photo notifications between %s and %s: %s", currentUser.Username, user.Username, err)
|
||||||
|
}
|
||||||
|
|
||||||
var message string
|
var message string
|
||||||
if verdict == "reject" {
|
if verdict == "reject" {
|
||||||
message = fmt.Sprintf("Friend request from %s has been rejected.", username)
|
message = fmt.Sprintf("Friend request from %s has been rejected.", username)
|
||||||
|
@ -70,6 +76,12 @@ func AddFriend() http.HandlerFunc {
|
||||||
session.Flash(w, r, message)
|
session.Flash(w, r, message)
|
||||||
if verdict == "reject" {
|
if verdict == "reject" {
|
||||||
templates.Redirect(w, "/friends?view=requests")
|
templates.Redirect(w, "/friends?view=requests")
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogDeleted(currentUser, nil, "friends", user.ID, "Rejected friend request from "+user.Username+".", nil)
|
||||||
|
} else {
|
||||||
|
// Log the change.
|
||||||
|
models.LogDeleted(currentUser, nil, "friends", user.ID, "Removed friendship with "+user.Username+".", nil)
|
||||||
}
|
}
|
||||||
templates.Redirect(w, "/friends")
|
templates.Redirect(w, "/friends")
|
||||||
return
|
return
|
||||||
|
@ -80,6 +92,9 @@ func AddFriend() http.HandlerFunc {
|
||||||
session.Flash(w, r, "You have ignored the friend request from %s.", username)
|
session.Flash(w, r, "You have ignored the friend request from %s.", username)
|
||||||
}
|
}
|
||||||
templates.Redirect(w, "/friends")
|
templates.Redirect(w, "/friends")
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogUpdated(currentUser, nil, "friends", user.ID, "Ignored the friend request from "+user.Username+".", nil)
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
// Post the friend request.
|
// Post the friend request.
|
||||||
|
@ -101,7 +116,28 @@ func AddFriend() http.HandlerFunc {
|
||||||
|
|
||||||
session.Flash(w, r, "You accepted the friend request from %s!", username)
|
session.Flash(w, r, "You accepted the friend request from %s!", username)
|
||||||
templates.Redirect(w, "/friends?view=requests")
|
templates.Redirect(w, "/friends?view=requests")
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogUpdated(currentUser, nil, "friends", user.ID, "Accepted friend request from "+user.Username+".", nil)
|
||||||
return
|
return
|
||||||
|
} else {
|
||||||
|
// Log the change.
|
||||||
|
models.LogCreated(currentUser, "friends", user.ID, "Sent a friend request to "+user.Username+".")
|
||||||
|
|
||||||
|
// Send a push notification to the recipient.
|
||||||
|
go func() {
|
||||||
|
// Opted out of this one?
|
||||||
|
if user.GetProfileField(config.PushNotificationOptOutFriends) == "true" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Try and send Web Push notification about new Friend Request to: %s", user.Username)
|
||||||
|
webpush.SendNotification(user, webpush.Payload{
|
||||||
|
Topic: "friend",
|
||||||
|
Title: "New Friend Request!",
|
||||||
|
Body: fmt.Sprintf("%s wants to be your friend on %s.", currentUser.Username, config.Title),
|
||||||
|
})
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
session.Flash(w, r, "Friend request sent!")
|
session.Flash(w, r, "Friend request sent!")
|
||||||
}
|
}
|
||||||
|
|
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"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/webpush"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Compose a new chat coming from a user's profile page.
|
// Compose a new chat coming from a user's profile page.
|
||||||
|
@ -61,9 +64,25 @@ func Compose() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send a push notification to the recipient.
|
||||||
|
go func() {
|
||||||
|
// Opted out of this one?
|
||||||
|
if user.GetProfileField(config.PushNotificationOptOutMessage) == "true" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Try and send Web Push notification about new Message to: %s", user.Username)
|
||||||
|
webpush.SendNotification(user, webpush.Payload{
|
||||||
|
Topic: "inbox",
|
||||||
|
Title: "New Message!",
|
||||||
|
Body: fmt.Sprintf("%s has left you a message on %s.", currentUser.Username, config.Title),
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
session.Flash(w, r, "Your message has been delivered!")
|
session.Flash(w, r, "Your message has been delivered!")
|
||||||
if from == "inbox" {
|
if from == "inbox" {
|
||||||
templates.Redirect(w, fmt.Sprintf("/messages/read/%d", m.ID))
|
templates.Redirect(w, fmt.Sprintf("/messages/read/%d", m.ID))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
templates.Redirect(w, "/messages")
|
templates.Redirect(w, "/messages")
|
||||||
return
|
return
|
||||||
|
|
|
@ -2,7 +2,6 @@ package inbox
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
@ -11,12 +10,16 @@ import (
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ReadURLRegexp = regexp.MustCompile(`^/messages/read/(\d+)$`)
|
|
||||||
|
|
||||||
// Inbox is where users receive direct messages.
|
// Inbox is where users receive direct messages.
|
||||||
func Inbox() http.HandlerFunc {
|
func Inbox() http.HandlerFunc {
|
||||||
tmpl := templates.Must("inbox/inbox.html")
|
tmpl := templates.Must("inbox/inbox.html")
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Message ID in path? (/messages/read/{id} endpoint)
|
||||||
|
var msgId int
|
||||||
|
if idStr := r.PathValue("id"); idStr != "" {
|
||||||
|
msgId, _ = strconv.Atoi(idStr)
|
||||||
|
}
|
||||||
|
|
||||||
currentUser, err := session.CurrentUser(r)
|
currentUser, err := session.CurrentUser(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
session.FlashError(w, r, "Unexpected error: could not get currentUser.")
|
session.FlashError(w, r, "Unexpected error: could not get currentUser.")
|
||||||
|
@ -35,10 +38,8 @@ func Inbox() http.HandlerFunc {
|
||||||
viewThread []*models.Message
|
viewThread []*models.Message
|
||||||
threadPager *models.Pagination
|
threadPager *models.Pagination
|
||||||
composeToUser *models.User
|
composeToUser *models.User
|
||||||
msgId int
|
|
||||||
)
|
)
|
||||||
if uri := ReadURLRegexp.FindStringSubmatch(r.URL.Path); uri != nil {
|
if msgId > 0 {
|
||||||
msgId, _ = strconv.Atoi(uri[1])
|
|
||||||
if msg, err := models.GetMessage(uint64(msgId)); err != nil {
|
if msg, err := models.GetMessage(uint64(msgId)); err != nil {
|
||||||
session.FlashError(w, r, "Message not found.")
|
session.FlashError(w, r, "Message not found.")
|
||||||
templates.Redirect(w, "/messages")
|
templates.Redirect(w, "/messages")
|
||||||
|
|
|
@ -26,13 +26,15 @@ func Contact() http.HandlerFunc {
|
||||||
subject = r.FormValue("subject")
|
subject = r.FormValue("subject")
|
||||||
title = "Contact Us"
|
title = "Contact Us"
|
||||||
message = r.FormValue("message")
|
message = r.FormValue("message")
|
||||||
|
footer string // appends to the message only when posting the feedback
|
||||||
replyTo = r.FormValue("email")
|
replyTo = r.FormValue("email")
|
||||||
trap1 = r.FormValue("url") != "https://"
|
trap1 = r.FormValue("url") != "https://"
|
||||||
trap2 = r.FormValue("comment") != ""
|
trap2 = r.FormValue("comment") != ""
|
||||||
tableID int
|
tableID int
|
||||||
tableName string
|
tableName string
|
||||||
tableLabel string // front-end user feedback about selected report item
|
tableLabel string // front-end user feedback about selected report item
|
||||||
messageRequired = true // unless we have a table ID to work with
|
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."
|
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"
|
tableName = "users"
|
||||||
if user, err := models.GetUser(uint64(tableID)); err == nil {
|
if user, err := models.GetUser(uint64(tableID)); err == nil {
|
||||||
tableLabel = fmt.Sprintf(`User account "%s"`, user.Username)
|
tableLabel = fmt.Sprintf(`User account "%s"`, user.Username)
|
||||||
|
aboutUser = user
|
||||||
} else {
|
} else {
|
||||||
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
|
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
|
||||||
}
|
}
|
||||||
|
@ -65,6 +68,7 @@ func Contact() http.HandlerFunc {
|
||||||
if pic, err := models.GetPhoto(uint64(tableID)); err == nil {
|
if pic, err := models.GetPhoto(uint64(tableID)); err == nil {
|
||||||
if user, err := models.GetUser(pic.UserID); err == nil {
|
if user, err := models.GetUser(pic.UserID); err == nil {
|
||||||
tableLabel = fmt.Sprintf(`A profile photo of user account "%s"`, user.Username)
|
tableLabel = fmt.Sprintf(`A profile photo of user account "%s"`, user.Username)
|
||||||
|
aboutUser = user
|
||||||
} else {
|
} else {
|
||||||
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
|
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
|
||||||
}
|
}
|
||||||
|
@ -74,12 +78,42 @@ func Contact() http.HandlerFunc {
|
||||||
case "report.message":
|
case "report.message":
|
||||||
tableName = "messages"
|
tableName = "messages"
|
||||||
tableLabel = "Direct Message conversation"
|
tableLabel = "Direct Message conversation"
|
||||||
|
|
||||||
|
// Find this message, and attach it to the report.
|
||||||
|
if msg, err := models.GetMessage(uint64(tableID)); err == nil {
|
||||||
|
var username = "[unavailable]"
|
||||||
|
if sender, err := models.GetUser(msg.SourceUserID); err == nil {
|
||||||
|
username = sender.Username
|
||||||
|
aboutUser = sender
|
||||||
|
}
|
||||||
|
|
||||||
|
footer = fmt.Sprintf(`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
From: <a href="/u/%s">@%s</a>
|
||||||
|
|
||||||
|
%s`,
|
||||||
|
username, username,
|
||||||
|
markdown.Quotify(msg.Message),
|
||||||
|
)
|
||||||
|
}
|
||||||
case "report.comment":
|
case "report.comment":
|
||||||
tableName = "comments"
|
tableName = "comments"
|
||||||
|
|
||||||
// Find this comment.
|
// Find this comment.
|
||||||
if comment, err := models.GetComment(uint64(tableID)); err == nil {
|
if comment, err := models.GetComment(uint64(tableID)); err == nil {
|
||||||
tableLabel = fmt.Sprintf(`A comment written by "%s"`, comment.User.Username)
|
tableLabel = fmt.Sprintf(`A comment written by "%s"`, comment.User.Username)
|
||||||
|
aboutUser = &comment.User
|
||||||
|
} else {
|
||||||
|
log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err)
|
||||||
|
}
|
||||||
|
case "report.forum", "forum.adopt":
|
||||||
|
tableName = "forums"
|
||||||
|
|
||||||
|
// Find this forum.
|
||||||
|
if forum, err := models.GetForum(uint64(tableID)); err == nil {
|
||||||
|
tableLabel = fmt.Sprintf(`The forum "%s" (/f/%s)`, forum.Title, forum.Fragment)
|
||||||
} else {
|
} else {
|
||||||
log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err)
|
log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err)
|
||||||
}
|
}
|
||||||
|
@ -132,11 +166,15 @@ func Contact() http.HandlerFunc {
|
||||||
fb := &models.Feedback{
|
fb := &models.Feedback{
|
||||||
Intent: intent,
|
Intent: intent,
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
Message: message,
|
Message: message + footer,
|
||||||
TableName: tableName,
|
TableName: tableName,
|
||||||
TableID: uint64(tableID),
|
TableID: uint64(tableID),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if aboutUser != nil {
|
||||||
|
fb.AboutUserID = aboutUser.ID
|
||||||
|
}
|
||||||
|
|
||||||
if currentUser != nil && currentUser.ID > 0 {
|
if currentUser != nil && currentUser.ID > 0 {
|
||||||
fb.UserID = currentUser.ID
|
fb.UserID = currentUser.ID
|
||||||
} else if replyTo != "" {
|
} else if replyTo != "" {
|
||||||
|
|
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/config"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/models/demographic"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -18,7 +19,17 @@ func Create() http.HandlerFunc {
|
||||||
return
|
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)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -38,3 +49,12 @@ func Manifest() http.HandlerFunc {
|
||||||
http.ServeFile(w, r, config.StaticPath+"/manifest.json")
|
http.ServeFile(w, r, config.StaticPath+"/manifest.json")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Service Worker for web push.
|
||||||
|
func ServiceWorker() http.HandlerFunc {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Add("Content-Type", "application/javascript; charset=UTF-8")
|
||||||
|
w.Header().Add("Service-Worker-Allowed", "/")
|
||||||
|
http.ServeFile(w, r, config.StaticPath+"/js/service-worker.js")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/encryption/coldstorage"
|
||||||
"code.nonshy.com/nonshy/website/pkg/geoip"
|
"code.nonshy.com/nonshy/website/pkg/geoip"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/mail"
|
"code.nonshy.com/nonshy/website/pkg/mail"
|
||||||
|
@ -70,6 +74,8 @@ func Certification() http.HandlerFunc {
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
// Are they deleting their photo?
|
// Are they deleting their photo?
|
||||||
if r.PostFormValue("delete") == "true" {
|
if r.PostFormValue("delete") == "true" {
|
||||||
|
|
||||||
|
// Primary cert photo
|
||||||
if cert.Filename != "" {
|
if cert.Filename != "" {
|
||||||
if err := photo.Delete(cert.Filename); err != nil {
|
if err := photo.Delete(cert.Filename); err != nil {
|
||||||
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.Filename, err)
|
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.Filename, err)
|
||||||
|
@ -77,8 +83,17 @@ func Certification() http.HandlerFunc {
|
||||||
cert.Filename = ""
|
cert.Filename = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Secondary cert photo
|
||||||
|
if cert.SecondaryFilename != "" {
|
||||||
|
if err := photo.Delete(cert.SecondaryFilename); err != nil {
|
||||||
|
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
|
||||||
|
}
|
||||||
|
cert.SecondaryFilename = ""
|
||||||
|
}
|
||||||
|
|
||||||
cert.Status = models.CertificationPhotoNeeded
|
cert.Status = models.CertificationPhotoNeeded
|
||||||
cert.AdminComment = ""
|
cert.AdminComment = ""
|
||||||
|
cert.SecondaryVerified = false
|
||||||
cert.Save()
|
cert.Save()
|
||||||
|
|
||||||
// Removing your photo = not certified again.
|
// Removing your photo = not certified again.
|
||||||
|
@ -87,11 +102,22 @@ func Certification() http.HandlerFunc {
|
||||||
session.FlashError(w, r, "Error saving your User data: %s", err)
|
session.FlashError(w, r, "Error saving your User data: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogDeleted(currentUser, nil, "certification_photos", currentUser.ID, "Removed their certification photo.", cert)
|
||||||
|
|
||||||
|
// Kick them from the chat room if they are online.
|
||||||
|
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
|
||||||
|
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
session.Flash(w, r, "Your certification photo has been deleted.")
|
session.Flash(w, r, "Your certification photo has been deleted.")
|
||||||
templates.Redirect(w, r.URL.Path)
|
templates.Redirect(w, r.URL.Path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Is it their secondary form of ID being uploaded?
|
||||||
|
isSecondary := r.PostFormValue("secondary") == "true"
|
||||||
|
|
||||||
// Get the uploaded file.
|
// Get the uploaded file.
|
||||||
file, header, err := r.FormFile("file")
|
file, header, err := r.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -115,17 +141,38 @@ func Certification() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Are they replacing their old photo?
|
// Are they replacing their old photo?
|
||||||
if cert.Filename != "" {
|
if cert.Filename != "" && !isSecondary {
|
||||||
if err := photo.Delete(cert.Filename); err != nil {
|
if err := photo.Delete(cert.Filename); err != nil {
|
||||||
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.Filename, err)
|
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.Filename, err)
|
||||||
}
|
}
|
||||||
|
} else if isSecondary && cert.SecondaryFilename != "" {
|
||||||
|
if err := photo.Delete(cert.SecondaryFilename); err != nil {
|
||||||
|
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update their certification photo.
|
// Update their certification photo.
|
||||||
cert.Status = models.CertificationPhotoPending
|
cert.Status = models.CertificationPhotoPending
|
||||||
cert.Filename = filename
|
if isSecondary {
|
||||||
|
cert.SecondaryFilename = filename
|
||||||
|
cert.SecondaryNeeded = true
|
||||||
|
cert.SecondaryVerified = false
|
||||||
|
} else {
|
||||||
|
cert.Filename = filename
|
||||||
|
}
|
||||||
cert.AdminComment = ""
|
cert.AdminComment = ""
|
||||||
cert.IPAddress = utility.IPAddress(r)
|
cert.IPAddress = utility.IPAddress(r)
|
||||||
|
|
||||||
|
// Secondary ID workflow: if the user
|
||||||
|
// 1. Uploads a regular cert photo
|
||||||
|
// 2. An admin marks secondary ID as needed
|
||||||
|
// 3. They remove everything and reupload a new cert photo, without a secondary ID attached
|
||||||
|
// Then we don't e-mail the admin for approval yet, and move straight to Secondary ID Requested
|
||||||
|
// for the user to upload their secondary ID now.
|
||||||
|
if cert.Status == models.CertificationPhotoPending && cert.SecondaryNeeded && cert.SecondaryFilename == "" {
|
||||||
|
cert.Status = models.CertificationPhotoSecondary
|
||||||
|
}
|
||||||
|
|
||||||
if err := cert.Save(); err != nil {
|
if err := cert.Save(); err != nil {
|
||||||
session.FlashError(w, r, "Error saving your CertificationPhoto: %s", err)
|
session.FlashError(w, r, "Error saving your CertificationPhoto: %s", err)
|
||||||
templates.Redirect(w, r.URL.Path)
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
@ -138,19 +185,31 @@ func Certification() http.HandlerFunc {
|
||||||
session.FlashError(w, r, "Error saving your User data: %s", err)
|
session.FlashError(w, r, "Error saving your User data: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify the admin email to check out this photo.
|
// Kick them from the chat room if they are online.
|
||||||
if err := mail.Send(mail.Message{
|
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
|
||||||
To: config.Current.AdminEmail,
|
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
|
||||||
Subject: "New Certification Photo Needs Approval",
|
|
||||||
Template: "email/certification_admin.html",
|
|
||||||
Data: map[string]interface{}{
|
|
||||||
"User": currentUser,
|
|
||||||
"URL": config.Current.BaseURL + "/admin/photo/certification",
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
log.Error("Certification: failed to notify admins of pending photo: %s", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log the change. Note the original IP and GeoIP insights - we once saw a spammer upload
|
||||||
|
// their cert photo from Nigeria, and before we could reject it, they removed and reuploaded
|
||||||
|
// it from New York using a VPN. If it wasn't seen in real time, this might have slipped by.
|
||||||
|
var insights string
|
||||||
|
if i, err := geoip.GetRequestInsights(r); err == nil {
|
||||||
|
insights = i.String()
|
||||||
|
} else {
|
||||||
|
insights = "error: " + err.Error()
|
||||||
|
}
|
||||||
|
models.LogCreated(
|
||||||
|
currentUser,
|
||||||
|
"certification_photos",
|
||||||
|
currentUser.ID,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Uploaded a new certification photo.\n\n* From IP address: %s\n* GeoIP insight: %s",
|
||||||
|
cert.IPAddress,
|
||||||
|
insights,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
session.Flash(w, r, "Your certification photo has been uploaded and is now awaiting approval.")
|
session.Flash(w, r, "Your certification photo has been uploaded and is now awaiting approval.")
|
||||||
templates.Redirect(w, r.URL.Path)
|
templates.Redirect(w, r.URL.Path)
|
||||||
return
|
return
|
||||||
|
@ -284,9 +343,20 @@ func AdminCertification() http.HandlerFunc {
|
||||||
} else {
|
} else {
|
||||||
cert.Status = models.CertificationPhotoRejected
|
cert.Status = models.CertificationPhotoRejected
|
||||||
cert.AdminComment = comment
|
cert.AdminComment = comment
|
||||||
if comment == "(ignore)" {
|
if comment == "(ignore)" || comment == "(secondary)" {
|
||||||
cert.AdminComment = ""
|
cert.AdminComment = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// With a secondary photo ID? Remove the photo ID immediately.
|
||||||
|
if cert.SecondaryFilename != "" {
|
||||||
|
// Delete it immediately.
|
||||||
|
if err := photo.Delete(cert.SecondaryFilename); err != nil {
|
||||||
|
session.FlashError(w, r, "Failed to delete old secondary ID cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
|
||||||
|
}
|
||||||
|
cert.SecondaryFilename = ""
|
||||||
|
cert.SecondaryVerified = false
|
||||||
|
}
|
||||||
|
|
||||||
if err := cert.Save(); err != nil {
|
if err := cert.Save(); err != nil {
|
||||||
session.FlashError(w, r, "Failed to save CertificationPhoto: %s", err)
|
session.FlashError(w, r, "Failed to save CertificationPhoto: %s", err)
|
||||||
templates.Redirect(w, r.URL.Path)
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
@ -297,6 +367,14 @@ func AdminCertification() http.HandlerFunc {
|
||||||
user.Certified = false
|
user.Certified = false
|
||||||
user.Save()
|
user.Save()
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogEvent(user, currentUser, models.ChangeLogRejected, "certification_photos", user.ID, "Rejected the certification photo with comment: "+comment)
|
||||||
|
|
||||||
|
// Kick them from the chat room if they are online.
|
||||||
|
if _, err := chat.MaybeDisconnectUser(user); err != nil {
|
||||||
|
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Did we silently ignore it?
|
// Did we silently ignore it?
|
||||||
if comment == "(ignore)" {
|
if comment == "(ignore)" {
|
||||||
session.FlashError(w, r, "The certification photo was ignored with no comment, and will not notify the sender.")
|
session.FlashError(w, r, "The certification photo was ignored with no comment, and will not notify the sender.")
|
||||||
|
@ -304,6 +382,46 @@ func AdminCertification() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Secondary verification required: the user will be asked to upload a blacked-out
|
||||||
|
// photo ID to be certified again.
|
||||||
|
if comment == "(secondary)" {
|
||||||
|
cert.Status = models.CertificationPhotoSecondary
|
||||||
|
cert.SecondaryNeeded = true
|
||||||
|
cert.SecondaryVerified = false
|
||||||
|
if err := cert.Save(); err != nil {
|
||||||
|
log.Error("Error saving cert photo: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the user about this rejection.
|
||||||
|
notif := &models.Notification{
|
||||||
|
UserID: user.ID,
|
||||||
|
AboutUser: *user,
|
||||||
|
Type: models.NotificationCertSecondary,
|
||||||
|
Message: "A secondary form of photo ID is requested. Please [click here](/photo/certification) to learn more.",
|
||||||
|
}
|
||||||
|
if err := models.CreateNotification(notif); err != nil {
|
||||||
|
log.Error("Couldn't create rejection notification: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the user via email.
|
||||||
|
if err := mail.Send(mail.Message{
|
||||||
|
To: user.Email,
|
||||||
|
Subject: "Regarding your nonshy certification photo",
|
||||||
|
Template: "email/certification_secondary.html",
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"Username": user.Username,
|
||||||
|
"AdminComment": comment,
|
||||||
|
"URL": config.Current.BaseURL + "/photo/certification",
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
session.FlashError(w, r, "Note: failed to email user about the rejection: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Flash(w, r, "The user will be asked to provide a secondary form of ID.")
|
||||||
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Notify the user about this rejection.
|
// Notify the user about this rejection.
|
||||||
notif := &models.Notification{
|
notif := &models.Notification{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
|
@ -316,17 +434,21 @@ func AdminCertification() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify the user via email.
|
// Notify the user via email.
|
||||||
if err := mail.Send(mail.Message{
|
if err := mail.LockSending("cert_rejected", user.Email, config.EmailDebounceDefault); err == nil {
|
||||||
To: user.Email,
|
if err := mail.Send(mail.Message{
|
||||||
Subject: "Your certification photo has been rejected",
|
To: user.Email,
|
||||||
Template: "email/certification_rejected.html",
|
Subject: "Your certification photo has been denied",
|
||||||
Data: map[string]interface{}{
|
Template: "email/certification_rejected.html",
|
||||||
"Username": user.Username,
|
Data: map[string]interface{}{
|
||||||
"AdminComment": comment,
|
"Username": user.Username,
|
||||||
"URL": config.Current.BaseURL + "/photo/certification",
|
"AdminComment": comment,
|
||||||
},
|
"URL": config.Current.BaseURL + "/photo/certification",
|
||||||
}); err != nil {
|
},
|
||||||
session.FlashError(w, r, "Note: failed to email user about the rejection: %s", err)
|
}); 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":
|
case "approve":
|
||||||
cert.Status = models.CertificationPhotoApproved
|
cert.Status = models.CertificationPhotoApproved
|
||||||
cert.AdminComment = ""
|
cert.AdminComment = ""
|
||||||
|
|
||||||
|
// With a secondary photo ID?
|
||||||
|
if cert.SecondaryFilename != "" {
|
||||||
|
// Move the original photo into cold storage.
|
||||||
|
coldStorageFilename := fmt.Sprintf(
|
||||||
|
"photoID-%d-%s-%d.jpg",
|
||||||
|
user.ID,
|
||||||
|
user.Username,
|
||||||
|
time.Now().Unix(),
|
||||||
|
)
|
||||||
|
if err := coldstorage.FileToColdStorage(
|
||||||
|
photo.DiskPath(cert.SecondaryFilename),
|
||||||
|
coldStorageFilename,
|
||||||
|
config.Current.Encryption.ColdStorageRSAPublicKey,
|
||||||
|
); err != nil {
|
||||||
|
session.FlashError(w, r, "Failed to move to cold storage: %s", err)
|
||||||
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
session.Flash(w, r, "Note: the secondary photo ID was encrypted to cold storage @ %s", coldStorageFilename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete it immediately.
|
||||||
|
if err := photo.Delete(cert.SecondaryFilename); err != nil {
|
||||||
|
session.FlashError(w, r, "Failed to delete old secondary ID cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
|
||||||
|
}
|
||||||
|
cert.SecondaryFilename = ""
|
||||||
|
cert.SecondaryVerified = true
|
||||||
|
}
|
||||||
|
|
||||||
if err := cert.Save(); err != nil {
|
if err := cert.Save(); err != nil {
|
||||||
session.FlashError(w, r, "Failed to save CertificationPhoto: %s", err)
|
session.FlashError(w, r, "Failed to save CertificationPhoto: %s", err)
|
||||||
templates.Redirect(w, r.URL.Path)
|
templates.Redirect(w, r.URL.Path)
|
||||||
|
@ -355,18 +507,25 @@ func AdminCertification() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify the user via email.
|
// Notify the user via email.
|
||||||
if err := mail.Send(mail.Message{
|
if err := mail.LockSending("cert_approved", user.Email, config.EmailDebounceDefault); err == nil {
|
||||||
To: user.Email,
|
if err := mail.Send(mail.Message{
|
||||||
Subject: "Your certification photo has been approved!",
|
To: user.Email,
|
||||||
Template: "email/certification_approved.html",
|
Subject: "Your certification photo has been approved!",
|
||||||
Data: map[string]interface{}{
|
Template: "email/certification_approved.html",
|
||||||
"Username": user.Username,
|
Data: map[string]interface{}{
|
||||||
"URL": config.Current.BaseURL,
|
"Username": user.Username,
|
||||||
},
|
"URL": config.Current.BaseURL,
|
||||||
}); err != nil {
|
},
|
||||||
session.FlashError(w, r, "Note: failed to email user about the approval: %s", err)
|
}); 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!")
|
session.Flash(w, r, "Certification photo approved!")
|
||||||
default:
|
default:
|
||||||
session.FlashError(w, r, "Unsupported verdict option: %s", verdict)
|
session.FlashError(w, r, "Unsupported verdict option: %s", verdict)
|
||||||
|
|
|
@ -5,7 +5,9 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
|
@ -42,9 +44,13 @@ func Edit() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In case an admin is editing this photo: remember the HTTP request current user,
|
||||||
|
// before the currentUser may be set to the photo's owner below.
|
||||||
|
var requestUser = currentUser
|
||||||
|
|
||||||
// Do we have permission for this photo?
|
// Do we have permission for this photo?
|
||||||
if photo.UserID != currentUser.ID {
|
if photo.UserID != currentUser.ID {
|
||||||
if !currentUser.IsAdmin {
|
if !currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||||
templates.ForbiddenPage(w, r)
|
templates.ForbiddenPage(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -65,29 +71,69 @@ func Edit() http.HandlerFunc {
|
||||||
|
|
||||||
// Are we saving the changes?
|
// Are we saving the changes?
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
|
// Record if this change is going to make them a Shy Account.
|
||||||
|
var wasShy = currentUser.IsShy()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
caption = r.FormValue("caption")
|
caption = strings.TrimSpace(r.FormValue("caption"))
|
||||||
|
altText = strings.TrimSpace(r.FormValue("alt_text"))
|
||||||
isExplicit = r.FormValue("explicit") == "true"
|
isExplicit = r.FormValue("explicit") == "true"
|
||||||
isGallery = r.FormValue("gallery") == "true"
|
isGallery = r.FormValue("gallery") == "true"
|
||||||
|
isPinned = r.FormValue("pinned") == "true"
|
||||||
visibility = models.PhotoVisibility(r.FormValue("visibility"))
|
visibility = models.PhotoVisibility(r.FormValue("visibility"))
|
||||||
|
|
||||||
// Profile pic fields
|
// Profile pic fields
|
||||||
setProfilePic = r.FormValue("intent") == "profile-pic"
|
setProfilePic = r.FormValue("intent") == "profile-pic"
|
||||||
crop = pphoto.ParseCropCoords(r.FormValue("crop"))
|
crop = pphoto.ParseCropCoords(r.FormValue("crop"))
|
||||||
|
|
||||||
// Are we GOING private or changing to Inner Circle?
|
// Are we GOING private?
|
||||||
goingPrivate = visibility == models.PhotoPrivate && visibility != photo.Visibility
|
goingPrivate = visibility == models.PhotoPrivate && visibility != photo.Visibility
|
||||||
goingCircle = visibility == models.PhotoInnerCircle && visibility != photo.Visibility
|
|
||||||
|
// Is the user fighting an 'Explicit' tag added by the community?
|
||||||
|
isFightingExplicitFlag = photo.Flagged && photo.Explicit && !isExplicit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if len(altText) > config.AltTextMaxLength {
|
||||||
|
altText = altText[:config.AltTextMaxLength]
|
||||||
|
}
|
||||||
|
|
||||||
// Respect the Site Gallery throttle in case the user is messing around.
|
// Respect the Site Gallery throttle in case the user is messing around.
|
||||||
if SiteGalleryThrottled {
|
if SiteGalleryThrottled {
|
||||||
isGallery = false
|
isGallery = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Diff for the ChangeLog.
|
||||||
|
diffs := []models.FieldDiff{
|
||||||
|
models.NewFieldDiff("Caption", photo.Caption, caption),
|
||||||
|
models.NewFieldDiff("Explicit", photo.Explicit, isExplicit),
|
||||||
|
models.NewFieldDiff("Gallery", photo.Gallery, isGallery),
|
||||||
|
models.NewFieldDiff("Pinned", photo.Pinned, isPinned),
|
||||||
|
models.NewFieldDiff("Visibility", photo.Visibility, visibility),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin label options.
|
||||||
|
if requestUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||||
|
var adminLabel string
|
||||||
|
if labels, ok := r.PostForm["admin_label"]; ok {
|
||||||
|
adminLabel = strings.Join(labels, ",")
|
||||||
|
}
|
||||||
|
diffs = append(diffs,
|
||||||
|
models.NewFieldDiff("Admin Label", photo.AdminLabel, adminLabel),
|
||||||
|
)
|
||||||
|
|
||||||
|
photo.AdminLabel = adminLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin label: forced explicit?
|
||||||
|
if photo.HasAdminLabelForceExplicit() {
|
||||||
|
isExplicit = true
|
||||||
|
}
|
||||||
|
|
||||||
photo.Caption = caption
|
photo.Caption = caption
|
||||||
|
photo.AltText = altText
|
||||||
photo.Explicit = isExplicit
|
photo.Explicit = isExplicit
|
||||||
photo.Gallery = isGallery
|
photo.Gallery = isGallery
|
||||||
|
photo.Pinned = isPinned
|
||||||
photo.Visibility = visibility
|
photo.Visibility = visibility
|
||||||
|
|
||||||
// Can not use a GIF as profile pic.
|
// Can not use a GIF as profile pic.
|
||||||
|
@ -116,7 +162,33 @@ func Edit() http.HandlerFunc {
|
||||||
setProfilePic = false
|
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 {
|
if err := photo.Save(); err != nil {
|
||||||
session.FlashError(w, r, "Couldn't save photo: %s", err)
|
session.FlashError(w, r, "Couldn't save photo: %s", err)
|
||||||
|
@ -134,14 +206,25 @@ func Edit() http.HandlerFunc {
|
||||||
// Flash success.
|
// Flash success.
|
||||||
session.Flash(w, r, "Photo settings updated!")
|
session.Flash(w, r, "Photo settings updated!")
|
||||||
|
|
||||||
|
// Log the change.
|
||||||
|
models.LogUpdated(currentUser, requestUser, "photos", photo.ID, "Updated the photo's settings.", diffs)
|
||||||
|
|
||||||
|
// Maybe kick them from the chat if this photo save makes them a Shy Account.
|
||||||
|
currentUser.FlushCaches()
|
||||||
|
if !wasShy && currentUser.IsShy() {
|
||||||
|
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
|
||||||
|
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If this picture has moved to Private, revoke any notification we gave about it before.
|
// If this picture has moved to Private, revoke any notification we gave about it before.
|
||||||
if goingPrivate || goingCircle {
|
if goingPrivate {
|
||||||
log.Info("The picture is GOING PRIVATE (to %s), revoke any notifications about it", photo.Visibility)
|
log.Info("The picture is GOING PRIVATE (to %s), revoke any notifications about it", photo.Visibility)
|
||||||
models.RemoveNotification("photos", photo.ID)
|
models.RemoveNotification("photos", photo.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the user to their gallery.
|
// Return the user to their gallery.
|
||||||
templates.Redirect(w, "/photo/u/"+currentUser.Username)
|
templates.Redirect(w, "/u/"+currentUser.Username+"/photos")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,6 +232,10 @@ func Edit() http.HandlerFunc {
|
||||||
"EditPhoto": photo,
|
"EditPhoto": photo,
|
||||||
"SiteGalleryThrottled": SiteGalleryThrottled,
|
"SiteGalleryThrottled": SiteGalleryThrottled,
|
||||||
"SiteGalleryThrottleLimit": config.SiteGalleryRateLimitMax,
|
"SiteGalleryThrottleLimit": config.SiteGalleryRateLimitMax,
|
||||||
|
|
||||||
|
// Available admin labels enum.
|
||||||
|
"RequestUser": requestUser,
|
||||||
|
"AvailableAdminLabels": config.AdminLabelPhotoOptions,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
|
@ -159,109 +246,10 @@ func Edit() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete controller (/photo/Delete?id=N) to change properties about your picture.
|
// Delete controller (/photo/Delete?id=N) to change properties about your picture.
|
||||||
|
//
|
||||||
|
// DEPRECATED: send them to the batch-edit endpoint.
|
||||||
func Delete() http.HandlerFunc {
|
func Delete() http.HandlerFunc {
|
||||||
// Reuse the upload page but with an EditPhoto variable.
|
|
||||||
tmpl := templates.Must("photo/delete.html")
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Query params.
|
templates.Redirect(w, fmt.Sprintf("/photo/batch-edit?intent=delete&id=%s", r.FormValue("id")))
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
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
|
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.
|
// Map reverse grantee statuses.
|
||||||
var GranteeMap interface{}
|
var GranteeMap interface{}
|
||||||
|
@ -60,6 +64,12 @@ func Private() http.HandlerFunc {
|
||||||
"GranteeMap": GranteeMap,
|
"GranteeMap": GranteeMap,
|
||||||
"Users": users,
|
"Users": users,
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
|
|
||||||
|
// Mapped user statuses for frontend cards.
|
||||||
|
"PhotoCountMap": models.MapPhotoCountsByVisibility(users, models.PhotoPrivate),
|
||||||
|
"FriendMap": models.MapFriends(currentUser, users),
|
||||||
|
"LikedMap": models.MapLikes(currentUser, "users", userIDs),
|
||||||
|
"ShyMap": models.MapShyAccounts(users),
|
||||||
}
|
}
|
||||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
@ -131,6 +141,15 @@ func Share() http.HandlerFunc {
|
||||||
intent = r.PostFormValue("intent")
|
intent = r.PostFormValue("intent")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Is the recipient blocking this photo share?
|
||||||
|
if intent != "decline" && intent != "revoke" {
|
||||||
|
if ok, err := models.ShouldShowPrivateUnlockPrompt(currentUser, user); !ok {
|
||||||
|
session.FlashError(w, r, "You are unable to share your private photos with %s: %s.", user.Username, err)
|
||||||
|
templates.Redirect(w, "/u/"+user.Username)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If submitting, do it and redirect.
|
// If submitting, do it and redirect.
|
||||||
if intent == "submit" {
|
if intent == "submit" {
|
||||||
models.UnlockPrivatePhotos(currentUser.ID, user.ID)
|
models.UnlockPrivatePhotos(currentUser.ID, user.ID)
|
||||||
|
@ -145,7 +164,7 @@ func Share() http.HandlerFunc {
|
||||||
Type: models.NotificationPrivatePhoto,
|
Type: models.NotificationPrivatePhoto,
|
||||||
TableName: "__private_photos",
|
TableName: "__private_photos",
|
||||||
TableID: currentUser.ID,
|
TableID: currentUser.ID,
|
||||||
Link: fmt.Sprintf("/photo/u/%s?visibility=private", currentUser.Username),
|
Link: fmt.Sprintf("/u/%s/photos?visibility=private", currentUser.Username),
|
||||||
}
|
}
|
||||||
if err := models.CreateNotification(notif); err != nil {
|
if err := models.CreateNotification(notif); err != nil {
|
||||||
log.Error("Couldn't create PrivatePhoto notification: %s", err)
|
log.Error("Couldn't create PrivatePhoto notification: %s", err)
|
||||||
|
@ -162,10 +181,25 @@ func Share() http.HandlerFunc {
|
||||||
models.RemoveSpecificNotification(user.ID, models.NotificationPrivatePhoto, "__private_photos", currentUser.ID)
|
models.RemoveSpecificNotification(user.ID, models.NotificationPrivatePhoto, "__private_photos", currentUser.ID)
|
||||||
|
|
||||||
// Revoke any "has uploaded a new private photo" notifications in this user's list.
|
// Revoke any "has uploaded a new private photo" notifications in this user's list.
|
||||||
if err := models.RevokePrivatePhotoNotifications(currentUser, &user.ID); err != nil {
|
if err := models.RevokePrivatePhotoNotifications(currentUser, user); err != nil {
|
||||||
log.Error("RevokePrivatePhotoNotifications(%s): %s", currentUser.Username, err)
|
log.Error("RevokePrivatePhotoNotifications(%s): %s", currentUser.Username, err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
} else if intent == "decline" {
|
||||||
|
// Decline = they shared with me and we do not want it.
|
||||||
|
models.RevokePrivatePhotos(user.ID, currentUser.ID)
|
||||||
|
session.Flash(w, r, "You have declined access to see %s's private photos.", user.Username)
|
||||||
|
|
||||||
|
// Remove any notification we created when the grant was given.
|
||||||
|
models.RemoveSpecificNotification(currentUser.ID, models.NotificationPrivatePhoto, "__private_photos", user.ID)
|
||||||
|
|
||||||
|
// Revoke any "has uploaded a new private photo" notifications in this user's list.
|
||||||
|
if err := models.RevokePrivatePhotoNotifications(user, currentUser); err != nil {
|
||||||
|
log.Error("RevokePrivatePhotoNotifications(%s): %s", user.Username, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
templates.Redirect(w, "/photo/private?view=grantee")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The other intent is "preview" so the user gets the confirmation
|
// The other intent is "preview" so the user gets the confirmation
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
@ -17,6 +18,9 @@ func SiteGallery() http.HandlerFunc {
|
||||||
var sortWhitelist = []string{
|
var sortWhitelist = []string{
|
||||||
"created_at desc",
|
"created_at desc",
|
||||||
"created_at asc",
|
"created_at asc",
|
||||||
|
"like_count desc",
|
||||||
|
"comment_count desc",
|
||||||
|
"views desc",
|
||||||
}
|
}
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
@ -64,13 +68,18 @@ func SiteGallery() http.HandlerFunc {
|
||||||
// They didn't post a "Whose photos" filter, restore it from their last saved default.
|
// They didn't post a "Whose photos" filter, restore it from their last saved default.
|
||||||
who = currentUser.GetProfileField("site_gallery_default")
|
who = currentUser.GetProfileField("site_gallery_default")
|
||||||
}
|
}
|
||||||
if who != "friends" && who != "everybody" && who != "friends+private" {
|
if who != "friends" && who != "everybody" && who != "friends+private" && who != "likes" && who != "uncertified" {
|
||||||
// Default Who setting should be Friends-only, unless you have no friends.
|
// Default Who setting should be Friends-only, unless you have no friends.
|
||||||
if myFriendCount > 0 {
|
if myFriendCount > 0 {
|
||||||
who = "friends"
|
who = "friends"
|
||||||
} else {
|
} else {
|
||||||
who = "everybody"
|
who = "everybody"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin only who option.
|
||||||
|
if who == "uncertified" && !currentUser.HasAdminScope(config.ScopePhotoModerator) {
|
||||||
|
who = "friends"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store their "Whose photos" filter on their page to default it for next time.
|
// Store their "Whose photos" filter on their page to default it for next time.
|
||||||
|
@ -94,6 +103,8 @@ func SiteGallery() http.HandlerFunc {
|
||||||
AdminView: adminView,
|
AdminView: adminView,
|
||||||
FriendsOnly: who == "friends",
|
FriendsOnly: who == "friends",
|
||||||
IsShy: isShy || who == "friends+private",
|
IsShy: isShy || who == "friends+private",
|
||||||
|
MyLikes: who == "likes",
|
||||||
|
Uncertified: who == "uncertified",
|
||||||
}, pager)
|
}, pager)
|
||||||
|
|
||||||
// Bulk load the users associated with these photos.
|
// Bulk load the users associated with these photos.
|
||||||
|
@ -114,6 +125,13 @@ func SiteGallery() http.HandlerFunc {
|
||||||
likeMap := models.MapLikes(currentUser, "photos", photoIDs)
|
likeMap := models.MapLikes(currentUser, "photos", photoIDs)
|
||||||
commentMap := models.MapCommentCounts("photos", photoIDs)
|
commentMap := models.MapCommentCounts("photos", photoIDs)
|
||||||
|
|
||||||
|
// Ping this user as having used the forums today.
|
||||||
|
go func() {
|
||||||
|
if err := models.LogDailyGalleryUser(currentUser); err != nil {
|
||||||
|
log.Error("LogDailyGalleryUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"IsSiteGallery": true,
|
"IsSiteGallery": true,
|
||||||
"Photos": photos,
|
"Photos": photos,
|
||||||
|
|
|
@ -2,10 +2,12 @@ package photo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
@ -59,10 +61,12 @@ func Upload() http.HandlerFunc {
|
||||||
// Are they POSTing?
|
// Are they POSTing?
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
var (
|
var (
|
||||||
caption = r.PostFormValue("caption")
|
caption = strings.TrimSpace(r.PostFormValue("caption"))
|
||||||
|
altText = strings.TrimSpace(r.PostFormValue("alt_text"))
|
||||||
isExplicit = r.PostFormValue("explicit") == "true"
|
isExplicit = r.PostFormValue("explicit") == "true"
|
||||||
visibility = r.PostFormValue("visibility")
|
visibility = r.PostFormValue("visibility")
|
||||||
isGallery = r.PostFormValue("gallery") == "true"
|
isGallery = r.PostFormValue("gallery") == "true"
|
||||||
|
isPinned = r.PostFormValue("pinned") == "true"
|
||||||
cropCoords = r.PostFormValue("crop")
|
cropCoords = r.PostFormValue("crop")
|
||||||
confirm1 = r.PostFormValue("confirm1") == "true"
|
confirm1 = r.PostFormValue("confirm1") == "true"
|
||||||
confirm2 = r.PostFormValue("confirm2") == "true"
|
confirm2 = r.PostFormValue("confirm2") == "true"
|
||||||
|
@ -73,10 +77,14 @@ func Upload() http.HandlerFunc {
|
||||||
isGallery = false
|
isGallery = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(altText) > config.AltTextMaxLength {
|
||||||
|
altText = altText[:config.AltTextMaxLength]
|
||||||
|
}
|
||||||
|
|
||||||
// Are they at quota already?
|
// Are they at quota already?
|
||||||
if photoCount >= photoQuota {
|
if photoCount >= photoQuota {
|
||||||
session.FlashError(w, r, "You have too many photos to upload a new one. Please delete a photo to make room for a new one.")
|
session.FlashError(w, r, "You have too many photos to upload a new one. Please delete a photo to make room for a new one.")
|
||||||
templates.Redirect(w, "/photo/u/"+user.Username)
|
templates.Redirect(w, "/u/"+user.Username+"/photos")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,8 +142,10 @@ func Upload() http.HandlerFunc {
|
||||||
Filename: filename,
|
Filename: filename,
|
||||||
CroppedFilename: cropFilename,
|
CroppedFilename: cropFilename,
|
||||||
Caption: caption,
|
Caption: caption,
|
||||||
|
AltText: altText,
|
||||||
Visibility: models.PhotoVisibility(visibility),
|
Visibility: models.PhotoVisibility(visibility),
|
||||||
Gallery: isGallery,
|
Gallery: isGallery,
|
||||||
|
Pinned: isPinned,
|
||||||
Explicit: isExplicit,
|
Explicit: isExplicit,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -159,11 +169,24 @@ func Upload() http.HandlerFunc {
|
||||||
user.Save()
|
user.Save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChangeLog entry.
|
||||||
|
models.LogCreated(user, "photos", p.ID, fmt.Sprintf(
|
||||||
|
"Uploaded a new photo.\n\n"+
|
||||||
|
"* Caption: %s\n"+
|
||||||
|
"* Visibility: %s\n"+
|
||||||
|
"* Gallery: %v\n"+
|
||||||
|
"* Explicit: %v",
|
||||||
|
p.Caption,
|
||||||
|
p.Visibility,
|
||||||
|
p.Gallery,
|
||||||
|
p.Explicit,
|
||||||
|
))
|
||||||
|
|
||||||
// Notify all of our friends that we posted a new picture.
|
// Notify all of our friends that we posted a new picture.
|
||||||
go notifyFriendsNewPhoto(p, user)
|
go notifyFriendsNewPhoto(p, user)
|
||||||
|
|
||||||
session.Flash(w, r, "Your photo has been uploaded successfully.")
|
session.Flash(w, r, "Your photo has been uploaded successfully.")
|
||||||
templates.Redirect(w, "/photo/u/"+user.Username)
|
templates.Redirect(w, "/u/"+user.Username+"/photos")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -199,15 +222,6 @@ func notifyFriendsNewPhoto(photo *models.Photo, currentUser *models.User) {
|
||||||
notifyUserIDs = models.PrivateGranteeUserIDs(currentUser.ID)
|
notifyUserIDs = models.PrivateGranteeUserIDs(currentUser.ID)
|
||||||
log.Info("Notify %d private grantees about the new photo by %s", len(notifyUserIDs), currentUser.Username)
|
log.Info("Notify %d private grantees about the new photo by %s", len(notifyUserIDs), currentUser.Username)
|
||||||
}
|
}
|
||||||
} else if photo.Visibility == models.PhotoInnerCircle {
|
|
||||||
// Inner circle members. If the pic is also Explicit, further narrow to explicit friend IDs.
|
|
||||||
if photo.Explicit {
|
|
||||||
notifyUserIDs = models.FriendIDsInCircleAreExplicit(currentUser.ID)
|
|
||||||
log.Info("Notify %d EXPLICIT circle friends about the new photo by %s", len(notifyUserIDs), currentUser.Username)
|
|
||||||
} else {
|
|
||||||
notifyUserIDs = models.FriendIDsInCircle(currentUser.ID)
|
|
||||||
log.Info("Notify %d circle friends about the new photo by %s", len(notifyUserIDs), currentUser.Username)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Friends only: we will notify exactly the friends we selected above.
|
// Friends only: we will notify exactly the friends we selected above.
|
||||||
notifyUserIDs = friendIDs
|
notifyUserIDs = friendIDs
|
||||||
|
|
|
@ -2,7 +2,6 @@ package photo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
@ -11,21 +10,24 @@ import (
|
||||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
var UserPhotosRegexp = regexp.MustCompile(`^/photo/u/([^@]+?)$`)
|
|
||||||
|
|
||||||
// UserPhotos controller (/photo/u/:username) to view a user's gallery or manage if it's yourself.
|
// UserPhotos controller (/photo/u/:username) to view a user's gallery or manage if it's yourself.
|
||||||
func UserPhotos() http.HandlerFunc {
|
func UserPhotos() http.HandlerFunc {
|
||||||
tmpl := templates.Must("photo/gallery.html")
|
tmpl := templates.Must("photo/gallery.html")
|
||||||
|
|
||||||
// Whitelist for ordering options.
|
// Whitelist for ordering options.
|
||||||
var sortWhitelist = []string{
|
var sortWhitelist = []string{
|
||||||
|
"pinned desc nulls last, updated_at desc",
|
||||||
"created_at desc",
|
"created_at desc",
|
||||||
"created_at asc",
|
"created_at asc",
|
||||||
|
"like_count desc",
|
||||||
|
"comment_count desc",
|
||||||
|
"views desc",
|
||||||
}
|
}
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Query params.
|
// Query params.
|
||||||
var (
|
var (
|
||||||
|
username = r.PathValue("username")
|
||||||
viewStyle = r.FormValue("view") // cards (default), full
|
viewStyle = r.FormValue("view") // cards (default), full
|
||||||
|
|
||||||
// Search filters.
|
// Search filters.
|
||||||
|
@ -33,9 +35,6 @@ func UserPhotos() http.HandlerFunc {
|
||||||
filterVisibility = r.FormValue("visibility")
|
filterVisibility = r.FormValue("visibility")
|
||||||
sort = r.FormValue("sort")
|
sort = r.FormValue("sort")
|
||||||
sortOK bool
|
sortOK bool
|
||||||
|
|
||||||
// Inner circle invite view?
|
|
||||||
innerCircleInvite = r.FormValue("intent") == "inner_circle"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sort options.
|
// Sort options.
|
||||||
|
@ -54,13 +53,6 @@ func UserPhotos() http.HandlerFunc {
|
||||||
viewStyle = "cards"
|
viewStyle = "cards"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the username out of the URL parameters.
|
|
||||||
var username string
|
|
||||||
m := UserPhotosRegexp.FindStringSubmatch(r.URL.Path)
|
|
||||||
if m != nil {
|
|
||||||
username = m[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find this user.
|
// Find this user.
|
||||||
user, err := models.FindUser(username)
|
user, err := models.FindUser(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -81,11 +73,6 @@ func UserPhotos() http.HandlerFunc {
|
||||||
isShyFrom = !isOwnPhotos && (currentUser.IsShyFrom(user) || (isShy && !areFriends))
|
isShyFrom = !isOwnPhotos && (currentUser.IsShyFrom(user) || (isShy && !areFriends))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Inner circle invite: not if we are not in the circle ourselves.
|
|
||||||
if innerCircleInvite && !currentUser.IsInnerCircle() {
|
|
||||||
innerCircleInvite = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bail early if we are shy from this user.
|
// Bail early if we are shy from this user.
|
||||||
if isShy && isShyFrom {
|
if isShy && isShyFrom {
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
|
@ -134,11 +121,6 @@ func UserPhotos() http.HandlerFunc {
|
||||||
visibility = append(visibility, models.PhotoFriends)
|
visibility = append(visibility, models.PhotoFriends)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inner circle photos.
|
|
||||||
if currentUser.IsInnerCircle() {
|
|
||||||
visibility = append(visibility, models.PhotoInnerCircle)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we are Filtering by Visibility, ensure the target visibility is accessible to us.
|
// If we are Filtering by Visibility, ensure the target visibility is accessible to us.
|
||||||
if filterVisibility != "" {
|
if filterVisibility != "" {
|
||||||
var isOK bool
|
var isOK bool
|
||||||
|
@ -203,13 +185,28 @@ func UserPhotos() http.HandlerFunc {
|
||||||
profilePictureHidden = visibility
|
profilePictureHidden = visibility
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Friend Photos Notification Opt-out:
|
||||||
|
// If your friend posts too many photos and you want to mute them.
|
||||||
|
// NOTE: notifications are "on by default" and only an explicit "false"
|
||||||
|
// stored in the database indicates an opt-out.
|
||||||
|
// New photo upload notification subscription status.
|
||||||
|
var areNotificationsMuted bool
|
||||||
|
if exists, v := models.IsSubscribed(currentUser, "friend.photos", user.ID); exists {
|
||||||
|
areNotificationsMuted = !v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should the current user be able to share their private photos with the target?
|
||||||
|
showPrivateUnlockPrompt, _ := models.ShouldShowPrivateUnlockPrompt(currentUser, user)
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"IsOwnPhotos": currentUser.ID == user.ID,
|
"IsOwnPhotos": currentUser.ID == user.ID,
|
||||||
"IsShyUser": isShy,
|
"IsShyUser": isShy,
|
||||||
"IsShyFrom": isShyFrom,
|
"IsShyFrom": isShyFrom,
|
||||||
"IsMyPrivateUnlockedFor": isGranted, // have WE granted THIS USER to see our private pics?
|
"IsMyPrivateUnlockedFor": isGranted, // have WE granted THIS USER to see our private pics?
|
||||||
"AreWeGrantedPrivate": isGrantee, // have THEY granted US private photo access.
|
"AreWeGrantedPrivate": isGrantee, // have THEY granted US private photo access.
|
||||||
|
"ShowPrivateUnlockPrompt": showPrivateUnlockPrompt,
|
||||||
"AreFriends": areFriends,
|
"AreFriends": areFriends,
|
||||||
|
"AreNotificationsMuted": areNotificationsMuted,
|
||||||
"ProfilePictureHiddenVisibility": profilePictureHidden,
|
"ProfilePictureHiddenVisibility": profilePictureHidden,
|
||||||
"User": user,
|
"User": user,
|
||||||
"Photos": photos,
|
"Photos": photos,
|
||||||
|
@ -217,13 +214,11 @@ func UserPhotos() http.HandlerFunc {
|
||||||
"NoteCount": models.CountNotesAboutUser(currentUser, user),
|
"NoteCount": models.CountNotesAboutUser(currentUser, user),
|
||||||
"FriendCount": models.CountFriends(user.ID),
|
"FriendCount": models.CountFriends(user.ID),
|
||||||
"PublicPhotoCount": models.CountPublicPhotos(user.ID),
|
"PublicPhotoCount": models.CountPublicPhotos(user.ID),
|
||||||
"InnerCircleMinimumPublicPhotos": config.InnerCircleMinimumPublicPhotos,
|
|
||||||
"Pager": pager,
|
"Pager": pager,
|
||||||
"LikeMap": likeMap,
|
"LikeMap": likeMap,
|
||||||
"CommentMap": commentMap,
|
"CommentMap": commentMap,
|
||||||
"ViewStyle": viewStyle,
|
"ViewStyle": viewStyle,
|
||||||
"ExplicitCount": explicitCount,
|
"ExplicitCount": explicitCount,
|
||||||
"InnerCircleInviteView": innerCircleInvite,
|
|
||||||
|
|
||||||
// Search filters
|
// Search filters
|
||||||
"Sort": sort,
|
"Sort": sort,
|
||||||
|
|
|
@ -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.
|
// Find the photo's owner.
|
||||||
user, err := models.GetUser(photo.UserID)
|
user, err := models.GetUser(photo.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -42,40 +48,10 @@ func View() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the current user in case they are viewing their own page.
|
if ok, err := photo.CanBeSeenBy(currentUser); !ok {
|
||||||
currentUser, err := session.CurrentUser(r)
|
log.Error("Photo %d can't be seen by %s: %s", photo.ID, currentUser.Username, err)
|
||||||
if err != nil {
|
session.FlashError(w, r, "Photo Not Found")
|
||||||
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
templates.Redirect(w, "/")
|
||||||
}
|
|
||||||
var isOwnPhoto = currentUser.ID == user.ID
|
|
||||||
|
|
||||||
// Is either one blocking?
|
|
||||||
if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
|
|
||||||
templates.NotFoundPage(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is this a circle photo?
|
|
||||||
if photo.Visibility == models.PhotoInnerCircle && !currentUser.IsInnerCircle() {
|
|
||||||
templates.NotFoundPage(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is this user private and we're not friends?
|
|
||||||
var (
|
|
||||||
areFriends = models.AreFriends(user.ID, currentUser.ID)
|
|
||||||
isPrivate = user.Visibility == models.UserVisibilityPrivate && !areFriends
|
|
||||||
)
|
|
||||||
if isPrivate && !currentUser.IsAdmin && !isOwnPhoto {
|
|
||||||
session.FlashError(w, r, "This user's profile page and photo gallery are private.")
|
|
||||||
templates.Redirect(w, "/u/"+user.Username)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is this a private photo and are we allowed to see?
|
|
||||||
isGranted := models.IsPrivateUnlocked(user.ID, currentUser.ID)
|
|
||||||
if photo.Visibility == models.PhotoPrivate && !isGranted && !isOwnPhoto && !currentUser.IsAdmin {
|
|
||||||
templates.NotFoundPage(w, r)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -108,6 +84,11 @@ func View() http.HandlerFunc {
|
||||||
// Is the current user subscribed to notifications on this thread?
|
// Is the current user subscribed to notifications on this thread?
|
||||||
_, isSubscribed := models.IsSubscribed(currentUser, "photos", photo.ID)
|
_, isSubscribed := models.IsSubscribed(currentUser, "photos", photo.ID)
|
||||||
|
|
||||||
|
// Mark this photo as "Viewed" by the user.
|
||||||
|
if err := photo.View(currentUser); err != nil {
|
||||||
|
log.Error("Update photo(%d) views: %s", photo.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
var vars = map[string]interface{}{
|
var vars = map[string]interface{}{
|
||||||
"IsOwnPhoto": currentUser.ID == user.ID,
|
"IsOwnPhoto": currentUser.ID == user.ID,
|
||||||
"User": user,
|
"User": user,
|
||||||
|
|
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
|
package encryption
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/encryption/keygen"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Encrypt a byte stream using the site's AES passphrase.
|
// Encrypt a byte stream using the site's AES passphrase.
|
||||||
|
@ -24,32 +21,7 @@ func Encrypt(input []byte) ([]byte, error) {
|
||||||
return nil, errors.New("AES key not configured")
|
return nil, errors.New("AES key not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a new AES cipher.
|
return keygen.EncryptWithAESKey(input, config.Current.Encryption.AESKey)
|
||||||
c, err := aes.NewCipher(config.Current.Encryption.AESKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// gcm or Galois/Counter Mode
|
|
||||||
gcm, err := cipher.NewGCM(c)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new byte array the size of the GCM nonce
|
|
||||||
// which must be passed to Seal.
|
|
||||||
nonce := make([]byte, gcm.NonceSize())
|
|
||||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
||||||
return nil, fmt.Errorf("populating the nonce: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt the text using the Seal function.
|
|
||||||
// Seal encrypts and authenticates plaintext, authenticates the
|
|
||||||
// additional data and appends the result to dst, returning the
|
|
||||||
// updated slice. The nonce must be NonceSize() bytes long and
|
|
||||||
// unique for all time, for a given key.
|
|
||||||
result := gcm.Seal(nonce, nonce, input, nil)
|
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EncryptString encrypts a string value and returns the cipher text.
|
// EncryptString encrypts a string value and returns the cipher text.
|
||||||
|
@ -63,27 +35,7 @@ func Decrypt(data []byte) ([]byte, error) {
|
||||||
return nil, errors.New("AES key not configured")
|
return nil, errors.New("AES key not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
c, err := aes.NewCipher(config.Current.Encryption.AESKey)
|
return keygen.DecryptWithAESKey(data, config.Current.Encryption.AESKey)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
gcm, err := cipher.NewGCM(c)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
nonceSize := gcm.NonceSize()
|
|
||||||
if len(data) < nonceSize {
|
|
||||||
return nil, errors.New("ciphertext data less than nonceSize")
|
|
||||||
}
|
|
||||||
|
|
||||||
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
|
|
||||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return plaintext, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecryptString decrypts a string value from ciphertext.
|
// DecryptString decrypts a string value from ciphertext.
|
||||||
|
|
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"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/encryption"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/redis"
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
"gopkg.in/gomail.v2"
|
"gopkg.in/gomail.v2"
|
||||||
)
|
)
|
||||||
|
@ -23,6 +26,22 @@ type Message struct {
|
||||||
Data map[string]interface{}
|
Data map[string]interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LockSending emails to the same address within 24 hours, e.g.: on the signup form to reduce chance for spam abuse.
|
||||||
|
//
|
||||||
|
// Call this before calling Send() if you want to throttle the sending. This function will put a key in Redis on
|
||||||
|
// the first call and return nil; on subsequent calls, if the key still remains, it will return an error.
|
||||||
|
func LockSending(namespace, email string, expires time.Duration) error {
|
||||||
|
var key = fmt.Sprintf("mail/lock-sending/%s/%s", namespace, encryption.Hash([]byte(email)))
|
||||||
|
|
||||||
|
// See if we have already locked it.
|
||||||
|
if redis.Exists(key) {
|
||||||
|
return errors.New("email was in the lock-sending queue")
|
||||||
|
}
|
||||||
|
|
||||||
|
redis.Set(key, email, expires)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Send an email.
|
// Send an email.
|
||||||
func Send(msg Message) error {
|
func Send(msg Message) error {
|
||||||
conf := config.Current.Mail
|
conf := config.Current.Mail
|
||||||
|
|
|
@ -22,7 +22,6 @@ func AgeGate(user *models.User, w http.ResponseWriter, r *http.Request) (handled
|
||||||
"/photo/certification",
|
"/photo/certification",
|
||||||
"/photo/private",
|
"/photo/private",
|
||||||
"/photo/view",
|
"/photo/view",
|
||||||
"/photo/u/",
|
|
||||||
"/comments",
|
"/comments",
|
||||||
"/users/blocked",
|
"/users/blocked",
|
||||||
"/users/block",
|
"/users/block",
|
||||||
|
|
|
@ -46,13 +46,19 @@ func LoginRequired(handler http.Handler) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ping LastLoginAt for long lived sessions, but not if impersonated.
|
// Ping LastLoginAt for long lived sessions, but not if impersonated.
|
||||||
|
var pingLastLoginAt bool
|
||||||
if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown && !session.Impersonated(r) {
|
if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown && !session.Impersonated(r) {
|
||||||
user.LastLoginAt = time.Now()
|
pingLastLoginAt = true
|
||||||
if err := user.Save(); err != nil {
|
if err := user.PingLastLoginAt(); err != nil {
|
||||||
log.Error("LoginRequired: couldn't refresh LastLoginAt for user %s: %s", user.Username, err)
|
log.Error("LoginRequired: couldn't refresh LastLoginAt for user %s: %s", user.Username, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log the last visit of their current IP address.
|
||||||
|
if err := models.PingIPAddress(r, user, pingLastLoginAt); err != nil {
|
||||||
|
log.Error("LoginRequired: couldn't ping user %s IP address: %s", user.Username, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Ask the user for their birthdate?
|
// Ask the user for their birthdate?
|
||||||
if AgeGate(user, w, r) {
|
if AgeGate(user, w, r) {
|
||||||
return
|
return
|
||||||
|
@ -115,6 +121,11 @@ func CertRequired(handler http.Handler) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log the last visit of their current IP address.
|
||||||
|
if err := models.PingIPAddress(r, currentUser, false); err != nil {
|
||||||
|
log.Error("CertRequired: couldn't ping user %s IP address: %s", currentUser.Username, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Are they banned?
|
// Are they banned?
|
||||||
if currentUser.Status == models.UserStatusBanned {
|
if currentUser.Status == models.UserStatusBanned {
|
||||||
session.LogoutUser(w, r)
|
session.LogoutUser(w, r)
|
||||||
|
|
|
@ -3,6 +3,7 @@ package middleware
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
@ -18,6 +19,9 @@ func CSRF(handler http.Handler) http.Handler {
|
||||||
token := MakeCSRFCookie(r, w)
|
token := MakeCSRFCookie(r, w)
|
||||||
ctx := context.WithValue(r.Context(), session.CSRFKey, token)
|
ctx := context.WithValue(r.Context(), session.CSRFKey, token)
|
||||||
|
|
||||||
|
// Store the request start time.
|
||||||
|
ctx = context.WithValue(ctx, session.RequestTimeKey, time.Now())
|
||||||
|
|
||||||
// If it's a JSON post, allow it thru.
|
// If it's a JSON post, allow it thru.
|
||||||
if r.Header.Get("Content-Type") == "application/json" {
|
if r.Header.Get("Content-Type") == "application/json" {
|
||||||
handler.ServeHTTP(w, r.WithContext(ctx))
|
handler.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
|
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
|
reverse = []*Block{} // Users who block the target
|
||||||
userIDs = []uint64{user.ID}
|
userIDs = []uint64{user.ID}
|
||||||
usernames = map[uint64]string{}
|
usernames = map[uint64]string{}
|
||||||
|
admins = map[uint64]bool{}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get the complete blocklist and bucket them into forward and reverse.
|
// Get the complete blocklist and bucket them into forward and reverse.
|
||||||
|
@ -218,6 +219,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
||||||
type scanItem struct {
|
type scanItem struct {
|
||||||
ID uint64
|
ID uint64
|
||||||
Username string
|
Username string
|
||||||
|
IsAdmin bool
|
||||||
}
|
}
|
||||||
var scan = []scanItem{}
|
var scan = []scanItem{}
|
||||||
if res := DB.Table(
|
if res := DB.Table(
|
||||||
|
@ -225,6 +227,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
||||||
).Select(
|
).Select(
|
||||||
"id",
|
"id",
|
||||||
"username",
|
"username",
|
||||||
|
"is_admin",
|
||||||
).Where(
|
).Where(
|
||||||
"id IN ?", userIDs,
|
"id IN ?", userIDs,
|
||||||
).Scan(&scan); res.Error != nil {
|
).Scan(&scan); res.Error != nil {
|
||||||
|
@ -233,6 +236,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
||||||
|
|
||||||
for _, row := range scan {
|
for _, row := range scan {
|
||||||
usernames[row.ID] = row.Username
|
usernames[row.ID] = row.Username
|
||||||
|
admins[row.ID] = row.IsAdmin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -245,6 +249,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
||||||
if username, ok := usernames[row.TargetUserID]; ok {
|
if username, ok := usernames[row.TargetUserID]; ok {
|
||||||
result.Blocks = append(result.Blocks, BlocklistInsightUser{
|
result.Blocks = append(result.Blocks, BlocklistInsightUser{
|
||||||
Username: username,
|
Username: username,
|
||||||
|
IsAdmin: admins[row.TargetUserID],
|
||||||
Date: row.CreatedAt,
|
Date: row.CreatedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -253,6 +258,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
|
||||||
if username, ok := usernames[row.SourceUserID]; ok {
|
if username, ok := usernames[row.SourceUserID]; ok {
|
||||||
result.BlockedBy = append(result.BlockedBy, BlocklistInsightUser{
|
result.BlockedBy = append(result.BlockedBy, BlocklistInsightUser{
|
||||||
Username: username,
|
Username: username,
|
||||||
|
IsAdmin: admins[row.SourceUserID],
|
||||||
Date: row.CreatedAt,
|
Date: row.CreatedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -268,6 +274,7 @@ type BlocklistInsight struct {
|
||||||
|
|
||||||
type BlocklistInsightUser struct {
|
type BlocklistInsightUser struct {
|
||||||
Username string
|
Username string
|
||||||
|
IsAdmin bool
|
||||||
Date time.Time
|
Date time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
@ -8,15 +9,18 @@ import (
|
||||||
|
|
||||||
// CertificationPhoto table.
|
// CertificationPhoto table.
|
||||||
type CertificationPhoto struct {
|
type CertificationPhoto struct {
|
||||||
ID uint64 `gorm:"primaryKey"`
|
ID uint64 `gorm:"primaryKey"`
|
||||||
UserID uint64 `gorm:"uniqueIndex"`
|
UserID uint64 `gorm:"uniqueIndex"`
|
||||||
Filename string
|
Filename string
|
||||||
Filesize int64
|
Filesize int64
|
||||||
Status CertificationPhotoStatus
|
Status CertificationPhotoStatus
|
||||||
AdminComment string
|
AdminComment string
|
||||||
IPAddress string // the IP they uploaded the photo from
|
SecondaryNeeded bool // a secondary form of ID has been requested
|
||||||
CreatedAt time.Time
|
SecondaryFilename string // photo ID upload
|
||||||
UpdatedAt time.Time
|
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
|
type CertificationPhotoStatus string
|
||||||
|
@ -26,6 +30,10 @@ const (
|
||||||
CertificationPhotoPending CertificationPhotoStatus = "pending"
|
CertificationPhotoPending CertificationPhotoStatus = "pending"
|
||||||
CertificationPhotoApproved CertificationPhotoStatus = "approved"
|
CertificationPhotoApproved CertificationPhotoStatus = "approved"
|
||||||
CertificationPhotoRejected CertificationPhotoStatus = "rejected"
|
CertificationPhotoRejected CertificationPhotoStatus = "rejected"
|
||||||
|
|
||||||
|
// If a photo is pending approval but the admin wants to engage the
|
||||||
|
// secondary check (prompt user for a photo ID upload)
|
||||||
|
CertificationPhotoSecondary CertificationPhotoStatus = "secondary"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetCertificationPhoto retrieves the user's record from the DB or upserts their initial record.
|
// GetCertificationPhoto retrieves the user's record from the DB or upserts their initial record.
|
||||||
|
@ -43,6 +51,28 @@ func GetCertificationPhoto(userID uint64) (*CertificationPhoto, error) {
|
||||||
return p, result.Error
|
return p, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CertifiedSince retrieve's the last updated date of the user's certification photo, if approved.
|
||||||
|
//
|
||||||
|
// This incurs a DB query for their cert photo.
|
||||||
|
func (u *User) CertifiedSince() (time.Time, error) {
|
||||||
|
if !u.Certified {
|
||||||
|
return time.Time{}, errors.New("user is not certified")
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, err := GetCertificationPhoto(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cert.Status != CertificationPhotoApproved {
|
||||||
|
// The edge case can come up if a user was manually certified but didn't have an approved picture.
|
||||||
|
// Return their CreatedAt instead.
|
||||||
|
return u.CreatedAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return cert.UpdatedAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CertificationPhotosNeedingApproval returns a pager of the pictures that require admin approval.
|
// CertificationPhotosNeedingApproval returns a pager of the pictures that require admin approval.
|
||||||
func CertificationPhotosNeedingApproval(status CertificationPhotoStatus, pager *Pagination) ([]*CertificationPhoto, error) {
|
func CertificationPhotosNeedingApproval(status CertificationPhotoStatus, pager *Pagination) ([]*CertificationPhoto, error) {
|
||||||
var p = []*CertificationPhoto{}
|
var p = []*CertificationPhoto{}
|
||||||
|
|
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.
|
// Comment table - in forum threads, on profiles or photos, etc.
|
||||||
type Comment struct {
|
type Comment struct {
|
||||||
ID uint64 `gorm:"primaryKey"`
|
ID uint64 `gorm:"primaryKey"`
|
||||||
TableName string `gorm:"index"`
|
TableName string `gorm:"index:idx_comment_composite"`
|
||||||
TableID uint64 `gorm:"index"`
|
TableID uint64 `gorm:"index:idx_comment_composite"`
|
||||||
UserID uint64 `gorm:"index"`
|
UserID uint64 `gorm:"index"`
|
||||||
User User
|
User User `json:"-"`
|
||||||
Message string
|
Message string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time `gorm:"index"`
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,6 +29,16 @@ var CommentableTables = map[string]interface{}{
|
||||||
"threads": nil,
|
"threads": nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubscribableTables are the set of table names that allow notification subscriptions.
|
||||||
|
var SubscribableTables = map[string]interface{}{
|
||||||
|
"photos": nil,
|
||||||
|
"threads": nil,
|
||||||
|
|
||||||
|
// Special case: new photo uploads from your friends. You can't comment on this,
|
||||||
|
// but you can (un)subscribe from it all the same.
|
||||||
|
"friend.photos": nil,
|
||||||
|
}
|
||||||
|
|
||||||
// Preload related tables for the forum (classmethod).
|
// Preload related tables for the forum (classmethod).
|
||||||
func (c *Comment) Preload() *gorm.DB {
|
func (c *Comment) Preload() *gorm.DB {
|
||||||
return DB.Preload("User.ProfilePhoto")
|
return DB.Preload("User.ProfilePhoto")
|
||||||
|
@ -94,21 +104,27 @@ func CountCommentsReceived(user *User) int64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaginateComments provides a page of comments on something.
|
// PaginateComments provides a page of comments on something.
|
||||||
func PaginateComments(user *User, tableName string, tableID uint64, pager *Pagination) ([]*Comment, error) {
|
//
|
||||||
|
// Note: noBlockLists is to facilitate user-owned forums, where forum owners/moderators should override the block lists
|
||||||
|
// and retain full visibility into all user comments on their forum. Default/recommended is to leave it false, where
|
||||||
|
// the user's block list filters the view.
|
||||||
|
func PaginateComments(user *User, tableName string, tableID uint64, noBlockLists bool, pager *Pagination) ([]*Comment, error) {
|
||||||
var (
|
var (
|
||||||
cs = []*Comment{}
|
cs = []*Comment{}
|
||||||
query = (&Comment{}).Preload()
|
query = (&Comment{}).Preload()
|
||||||
blockedUserIDs = BlockedUserIDs(user)
|
wheres = []string{}
|
||||||
wheres = []string{}
|
placeholders = []interface{}{}
|
||||||
placeholders = []interface{}{}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
wheres = append(wheres, "table_name = ? AND table_id = ?")
|
wheres = append(wheres, "table_name = ? AND table_id = ?")
|
||||||
placeholders = append(placeholders, tableName, tableID)
|
placeholders = append(placeholders, tableName, tableID)
|
||||||
|
|
||||||
if len(blockedUserIDs) > 0 {
|
if !noBlockLists {
|
||||||
wheres = append(wheres, "user_id NOT IN ?")
|
blockedUserIDs := BlockedUserIDs(user)
|
||||||
placeholders = append(placeholders, blockedUserIDs)
|
if len(blockedUserIDs) > 0 {
|
||||||
|
wheres = append(wheres, "user_id NOT IN ?")
|
||||||
|
placeholders = append(placeholders, blockedUserIDs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't show comments from banned or disabled accounts.
|
// Don't show comments from banned or disabled accounts.
|
||||||
|
@ -172,6 +188,14 @@ func FindPageByComment(user *User, comment *Comment, pageSize int) (int, error)
|
||||||
for i, cid := range allCommentIDs {
|
for i, cid := range allCommentIDs {
|
||||||
if cid == comment.ID {
|
if cid == comment.ID {
|
||||||
var page = int(math.Ceil(float64(i) / float64(pageSize)))
|
var page = int(math.Ceil(float64(i) / float64(pageSize)))
|
||||||
|
|
||||||
|
// If the comment index is an equal multiple of the page size
|
||||||
|
// (e.g. comment #20 is the 1st comment on page 2, since 0-19 is page 1),
|
||||||
|
// account for an off-by-one error.
|
||||||
|
if i%pageSize == 0 {
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
|
||||||
if page == 0 {
|
if page == 0 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
|
|
|
@ -89,9 +89,16 @@ func MapCommentPhotos(comments []*Comment) (CommentPhotoMap, error) {
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, c := range comments {
|
for _, c := range comments {
|
||||||
|
if c == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
IDs = append(IDs, c.ID)
|
IDs = append(IDs, c.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(IDs) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
res := DB.Model(&CommentPhoto{}).Where("comment_id IN ?", IDs).Find(&ps)
|
res := DB.Model(&CommentPhoto{}).Where("comment_id IN ?", IDs).Find(&ps)
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
return nil, res.Error
|
return nil, res.Error
|
||||||
|
@ -127,7 +134,15 @@ func GetOrphanedCommentPhotos() ([]*CommentPhoto, int64, error) {
|
||||||
ps = []*CommentPhoto{}
|
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)
|
query.Count(&count)
|
||||||
res := query.Limit(500).Find(&ps)
|
res := query.Limit(500).Find(&ps)
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
|
|
|
@ -3,6 +3,7 @@ package deletion
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
"code.nonshy.com/nonshy/website/pkg/photo"
|
"code.nonshy.com/nonshy/website/pkg/photo"
|
||||||
|
@ -12,6 +13,17 @@ import (
|
||||||
func DeleteUser(user *models.User) error {
|
func DeleteUser(user *models.User) error {
|
||||||
log.Error("BEGIN DeleteUser(%d, %s)", user.ID, user.Username)
|
log.Error("BEGIN DeleteUser(%d, %s)", user.ID, user.Username)
|
||||||
|
|
||||||
|
// Clear their history on the chat room.
|
||||||
|
go func() {
|
||||||
|
i, err := chat.EraseChatHistory(user.Username)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("EraseChatHistory(%s): %s", user.Username, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Error("DeleteUser(%s): Cleared chat DMs history for user (%d messages erased)", user.Username, i)
|
||||||
|
}()
|
||||||
|
|
||||||
// Remove all linked tables and assets.
|
// Remove all linked tables and assets.
|
||||||
type remover struct {
|
type remover struct {
|
||||||
Step string
|
Step string
|
||||||
|
@ -24,6 +36,8 @@ func DeleteUser(user *models.User) error {
|
||||||
// Tables to remove. In case of any unexpected DB errors, these tables are ordered
|
// Tables to remove. In case of any unexpected DB errors, these tables are ordered
|
||||||
// to remove the "safest" fields first.
|
// to remove the "safest" fields first.
|
||||||
var todo = []remover{
|
var todo = []remover{
|
||||||
|
{"Admin group memberships", DeleteAdminGroupUsers},
|
||||||
|
{"Disown User Forums", DisownForums},
|
||||||
{"Notifications", DeleteNotifications},
|
{"Notifications", DeleteNotifications},
|
||||||
{"Likes", DeleteLikes},
|
{"Likes", DeleteLikes},
|
||||||
{"Threads", DeleteForumThreads},
|
{"Threads", DeleteForumThreads},
|
||||||
|
@ -41,6 +55,11 @@ func DeleteUser(user *models.User) error {
|
||||||
{"Two Factor", DeleteTwoFactor},
|
{"Two Factor", DeleteTwoFactor},
|
||||||
{"Profile Fields", DeleteProfile},
|
{"Profile Fields", DeleteProfile},
|
||||||
{"User Notes", DeleteUserNotes},
|
{"User Notes", DeleteUserNotes},
|
||||||
|
{"Change Logs", DeleteChangeLogs},
|
||||||
|
{"IP Addresses", DeleteIPAddresses},
|
||||||
|
{"Push Notifications", DeletePushNotifications},
|
||||||
|
{"Forum Memberships", DeleteForumMemberships},
|
||||||
|
{"Usage Statistics", DeleteUsageStatistics},
|
||||||
}
|
}
|
||||||
for _, item := range todo {
|
for _, item := range todo {
|
||||||
if err := item.Fn(user.ID); err != nil {
|
if err := item.Fn(user.ID); err != nil {
|
||||||
|
@ -52,6 +71,16 @@ func DeleteUser(user *models.User) error {
|
||||||
return user.Delete()
|
return user.Delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteAdminGroupUsers scrubs data for deleting a user.
|
||||||
|
func DeleteAdminGroupUsers(userID uint64) error {
|
||||||
|
log.Error("DeleteUser: DeleteAdminGroupUsers(%d)", userID)
|
||||||
|
result := models.DB.Exec(
|
||||||
|
"DELETE FROM admin_group_users WHERE user_id = ?",
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteUserPhotos scrubs data for deleting a user.
|
// DeleteUserPhotos scrubs data for deleting a user.
|
||||||
func DeleteUserPhotos(userID uint64) error {
|
func DeleteUserPhotos(userID uint64) error {
|
||||||
log.Error("DeleteUser: BEGIN DeleteUserPhotos(%d)", userID)
|
log.Error("DeleteUser: BEGIN DeleteUserPhotos(%d)", userID)
|
||||||
|
@ -327,3 +356,64 @@ func DeleteUserNotes(userID uint64) error {
|
||||||
).Delete(&models.UserNote{})
|
).Delete(&models.UserNote{})
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteChangeLogs scrubs data for deleting a user.
|
||||||
|
func DeleteChangeLogs(userID uint64) error {
|
||||||
|
log.Error("DeleteUser: DeleteChangeLogs(%d)", userID)
|
||||||
|
result := models.DB.Where(
|
||||||
|
"about_user_id = ?",
|
||||||
|
userID,
|
||||||
|
).Delete(&models.ChangeLog{})
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteIPAddresses scrubs data for deleting a user.
|
||||||
|
func DeleteIPAddresses(userID uint64) error {
|
||||||
|
log.Error("DeleteUser: DeleteIPAddresses(%d)", userID)
|
||||||
|
result := models.DB.Where(
|
||||||
|
"user_id = ?",
|
||||||
|
userID,
|
||||||
|
).Delete(&models.IPAddress{})
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePushNotifications scrubs data for deleting a user.
|
||||||
|
func DeletePushNotifications(userID uint64) error {
|
||||||
|
log.Error("DeleteUser: DeletePushNotifications(%d)", userID)
|
||||||
|
result := models.DB.Where(
|
||||||
|
"user_id = ?",
|
||||||
|
userID,
|
||||||
|
).Delete(&models.PushNotification{})
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisownForums unlinks the user from their owned forums.
|
||||||
|
func DisownForums(userID uint64) error {
|
||||||
|
log.Error("DeleteUser: DisownForums(%d)", userID)
|
||||||
|
result := models.DB.Exec(`
|
||||||
|
UPDATE forums
|
||||||
|
SET owner_id = NULL
|
||||||
|
WHERE owner_id = ?
|
||||||
|
`, userID)
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteForumMemberships scrubs data for deleting a user.
|
||||||
|
func DeleteForumMemberships(userID uint64) error {
|
||||||
|
log.Error("DeleteUser: DeleteForumMemberships(%d)", userID)
|
||||||
|
result := models.DB.Where(
|
||||||
|
"user_id = ?",
|
||||||
|
userID,
|
||||||
|
).Delete(&models.ForumMembership{})
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUsageStatistics scrubs data for deleting a user.
|
||||||
|
func DeleteUsageStatistics(userID uint64) error {
|
||||||
|
log.Error("DeleteUser: DeleteUsageStatistics(%d)", userID)
|
||||||
|
result := models.DB.Where(
|
||||||
|
"user_id = ?",
|
||||||
|
userID,
|
||||||
|
).Delete(&models.UsageStatistic{})
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
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
|
// List of tables to export. Keep the ordering in sync with
|
||||||
// the AutoMigrate() calls in ../models.go
|
// the AutoMigrate() calls in ../models.go
|
||||||
var todo = []task{
|
var todo = []task{
|
||||||
{"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},
|
{"ProfileField", ExportProfileFieldTable},
|
||||||
{"Photo", ExportPhotoTable},
|
{"Photo", ExportPhotoTable},
|
||||||
{"PrivatePhoto", ExportPrivatePhotoTable},
|
|
||||||
{"CertificationPhoto", ExportCertificationPhotoTable},
|
|
||||||
{"Message", ExportMessageTable},
|
|
||||||
{"Friend", ExportFriendTable},
|
|
||||||
{"Block", ExportBlockTable},
|
|
||||||
{"Feedback", ExportFeedbackTable},
|
|
||||||
{"Forum", ExportForumTable},
|
|
||||||
{"Thread", ExportThreadTable},
|
|
||||||
{"Comment", ExportCommentTable},
|
|
||||||
{"Like", ExportLikeTable},
|
|
||||||
{"Notification", ExportNotificationTable},
|
|
||||||
{"Subscription", ExportSubscriptionTable},
|
|
||||||
{"CommentPhoto", ExportCommentPhotoTable},
|
|
||||||
// Note: Poll table is eager-loaded in Thread export
|
// Note: Poll table is eager-loaded in Thread export
|
||||||
{"PollVote", ExportPollVoteTable},
|
{"PollVote", ExportPollVoteTable},
|
||||||
// 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},
|
{"UserLocation", ExportUserLocationTable},
|
||||||
{"UserNote", ExportUserNoteTable},
|
{"UserNote", ExportUserNoteTable},
|
||||||
{"TwoFactor", ExportTwoFactorTable},
|
|
||||||
}
|
}
|
||||||
for _, item := range todo {
|
for _, item := range todo {
|
||||||
log.Info("Exporting data model: %s", item.Step)
|
log.Info("Exporting data model: %s", item.Step)
|
||||||
|
@ -383,6 +388,21 @@ func ExportUserNoteTable(zw *zip.Writer, user *models.User) error {
|
||||||
return ZipJson(zw, "user_notes.json", items)
|
return ZipJson(zw, "user_notes.json", items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ExportChangeLogTable(zw *zip.Writer, user *models.User) error {
|
||||||
|
var (
|
||||||
|
items = []*models.ChangeLog{}
|
||||||
|
query = models.DB.Model(&models.ChangeLog{}).Where(
|
||||||
|
"about_user_id = ? OR admin_user_id = ?",
|
||||||
|
user.ID, user.ID,
|
||||||
|
).Find(&items)
|
||||||
|
)
|
||||||
|
if query.Error != nil {
|
||||||
|
return query.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return ZipJson(zw, "change_logs.json", items)
|
||||||
|
}
|
||||||
|
|
||||||
func ExportUserLocationTable(zw *zip.Writer, user *models.User) error {
|
func ExportUserLocationTable(zw *zip.Writer, user *models.User) error {
|
||||||
var (
|
var (
|
||||||
items = []*models.UserLocation{}
|
items = []*models.UserLocation{}
|
||||||
|
@ -412,3 +432,63 @@ func ExportTwoFactorTable(zw *zip.Writer, user *models.User) error {
|
||||||
|
|
||||||
return ZipJson(zw, "two_factor.json", items)
|
return ZipJson(zw, "two_factor.json", items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ExportIPAddressTable(zw *zip.Writer, user *models.User) error {
|
||||||
|
var (
|
||||||
|
items = []*models.IPAddress{}
|
||||||
|
query = models.DB.Model(&models.IPAddress{}).Where(
|
||||||
|
"user_id = ?",
|
||||||
|
user.ID,
|
||||||
|
).Find(&items)
|
||||||
|
)
|
||||||
|
if query.Error != nil {
|
||||||
|
return query.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return ZipJson(zw, "ip_addresses.json", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExportForumMembershipTable(zw *zip.Writer, user *models.User) error {
|
||||||
|
var (
|
||||||
|
items = []*models.ForumMembership{}
|
||||||
|
query = models.DB.Model(&models.ForumMembership{}).Where(
|
||||||
|
"user_id = ?",
|
||||||
|
user.ID,
|
||||||
|
).Find(&items)
|
||||||
|
)
|
||||||
|
if query.Error != nil {
|
||||||
|
return query.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return ZipJson(zw, "forum_memberships.json", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExportPushNotificationTable(zw *zip.Writer, user *models.User) error {
|
||||||
|
var (
|
||||||
|
items = []*models.PushNotification{}
|
||||||
|
query = models.DB.Model(&models.PushNotification{}).Where(
|
||||||
|
"user_id = ?",
|
||||||
|
user.ID,
|
||||||
|
).Find(&items)
|
||||||
|
)
|
||||||
|
if query.Error != nil {
|
||||||
|
return query.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return ZipJson(zw, "push_notifications.json", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExportUsageStatisticTable(zw *zip.Writer, user *models.User) error {
|
||||||
|
var (
|
||||||
|
items = []*models.UsageStatistic{}
|
||||||
|
query = models.DB.Model(&models.UsageStatistic{}).Where(
|
||||||
|
"user_id = ?",
|
||||||
|
user.ID,
|
||||||
|
).Find(&items)
|
||||||
|
)
|
||||||
|
if query.Error != nil {
|
||||||
|
return query.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return ZipJson(zw, "usage_statistics.json", items)
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -11,6 +12,7 @@ import (
|
||||||
type Feedback struct {
|
type Feedback struct {
|
||||||
ID uint64 `gorm:"primaryKey"`
|
ID uint64 `gorm:"primaryKey"`
|
||||||
UserID uint64 `gorm:"index"` // if logged-in user posted this
|
UserID uint64 `gorm:"index"` // if logged-in user posted this
|
||||||
|
AboutUserID uint64 // associated 'about' user (e.g., owner of a reported photo)
|
||||||
Acknowledged bool `gorm:"index"` // admin dashboard "read" status
|
Acknowledged bool `gorm:"index"` // admin dashboard "read" status
|
||||||
Intent string
|
Intent string
|
||||||
Subject string
|
Subject string
|
||||||
|
@ -45,7 +47,7 @@ func CountUnreadFeedback() int64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaginateFeedback
|
// 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 (
|
var (
|
||||||
fb = []*Feedback{}
|
fb = []*Feedback{}
|
||||||
wheres = []string{}
|
wheres = []string{}
|
||||||
|
@ -60,6 +62,23 @@ func PaginateFeedback(acknowledged bool, intent string, pager *Pagination) ([]*F
|
||||||
placeholders = append(placeholders, intent)
|
placeholders = append(placeholders, intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if subject != "" {
|
||||||
|
wheres = append(wheres, "subject = ?")
|
||||||
|
placeholders = append(placeholders, subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search terms.
|
||||||
|
for _, term := range search.Includes {
|
||||||
|
var ilike = "%" + strings.ToLower(term) + "%"
|
||||||
|
wheres = append(wheres, "message ILIKE ?")
|
||||||
|
placeholders = append(placeholders, ilike)
|
||||||
|
}
|
||||||
|
for _, term := range search.Excludes {
|
||||||
|
var ilike = "%" + strings.ToLower(term) + "%"
|
||||||
|
wheres = append(wheres, "message NOT ILIKE ?")
|
||||||
|
placeholders = append(placeholders, ilike)
|
||||||
|
}
|
||||||
|
|
||||||
query := DB.Where(
|
query := DB.Where(
|
||||||
strings.Join(wheres, " AND "),
|
strings.Join(wheres, " AND "),
|
||||||
placeholders...,
|
placeholders...,
|
||||||
|
@ -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
|
// It returns reports where table_name=users and their user ID, or where table_name=photos and about any
|
||||||
// of their current photo IDs. Additionally, it will look for chat room reports which were about their
|
// of their current photo IDs. Additionally, it will look for chat room reports which were about their
|
||||||
// username.
|
// username.
|
||||||
func PaginateFeedbackAboutUser(user *User, pager *Pagination) ([]*Feedback, error) {
|
//
|
||||||
|
// The 'show' parameter applies some basic filter choices:
|
||||||
|
//
|
||||||
|
// - Blank string (default) = all reports From or About this user
|
||||||
|
// - "about" = all reports About this user (by table_name=users table_id=userID, or table_name=photos
|
||||||
|
// for any of their existing photo IDs)
|
||||||
|
// - "from" = all reports From this user (where reporting user_id is the user's ID)
|
||||||
|
// - "fuzzy" = fuzzy full text search on all reports that contain the user's username.
|
||||||
|
func PaginateFeedbackAboutUser(user *User, show string, pager *Pagination) ([]*Feedback, error) {
|
||||||
var (
|
var (
|
||||||
fb = []*Feedback{}
|
fb = []*Feedback{}
|
||||||
photoIDs, _ = user.AllPhotoIDs()
|
photoIDs, _ = user.AllPhotoIDs()
|
||||||
wheres = []string{}
|
wheres = []string{}
|
||||||
placeholders = []interface{}{}
|
placeholders = []interface{}{}
|
||||||
|
like = "%" + user.Username + "%"
|
||||||
)
|
)
|
||||||
|
|
||||||
wheres = append(wheres, `
|
// How to apply the search filters?
|
||||||
(table_name = 'users' AND table_id = ?) OR
|
switch show {
|
||||||
(table_name = 'photos' AND table_id IN ?)
|
case "about":
|
||||||
`)
|
wheres = append(wheres, `
|
||||||
placeholders = append(placeholders, user.ID, photoIDs)
|
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(
|
query := DB.Where(
|
||||||
strings.Join(wheres, " AND "),
|
strings.Join(wheres, " AND "),
|
||||||
|
@ -111,6 +160,22 @@ func PaginateFeedbackAboutUser(user *User, pager *Pagination) ([]*Feedback, erro
|
||||||
return fb, result.Error
|
return fb, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DistinctFeedbackSubjects returns the distinct subjects on feedback & reports.
|
||||||
|
func DistinctFeedbackSubjects() []string {
|
||||||
|
var results = []string{}
|
||||||
|
query := DB.Model(&Feedback{}).
|
||||||
|
Select("DISTINCT feedbacks.subject").
|
||||||
|
Group("feedbacks.subject").
|
||||||
|
Find(&results)
|
||||||
|
if query.Error != nil {
|
||||||
|
log.Error("DistinctFeedbackSubjects: %s", query.Error)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(results)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
// CreateFeedback saves a new Feedback row to the DB.
|
// CreateFeedback saves a new Feedback row to the DB.
|
||||||
func CreateFeedback(fb *Feedback) error {
|
func CreateFeedback(fb *Feedback) error {
|
||||||
result := DB.Create(fb)
|
result := DB.Create(fb)
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -20,14 +21,14 @@ type Forum struct {
|
||||||
Explicit bool `gorm:"index"`
|
Explicit bool `gorm:"index"`
|
||||||
Privileged bool
|
Privileged bool
|
||||||
PermitPhotos bool
|
PermitPhotos bool
|
||||||
InnerCircle bool
|
Private bool `gorm:"index"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preload related tables for the forum (classmethod).
|
// Preload related tables for the forum (classmethod).
|
||||||
func (f *Forum) Preload() *gorm.DB {
|
func (f *Forum) Preload() *gorm.DB {
|
||||||
return DB.Preload("Owner")
|
return DB.Preload("Owner").Preload("Owner.ProfilePhoto")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetForum by ID.
|
// GetForum by ID.
|
||||||
|
@ -69,6 +70,13 @@ func ForumByFragment(fragment string) (*Forum, error) {
|
||||||
return f, result.Error
|
return f, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanEdit checks if the user has edit rights over this forum.
|
||||||
|
//
|
||||||
|
// That is, they are its Owner or they are an admin with Manage Forums permission.
|
||||||
|
func (f *Forum) CanEdit(user *User) bool {
|
||||||
|
return user.HasAdminScope(config.ScopeForumAdmin) || f.OwnerID == user.ID
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
PaginateForums scans over the available forums for a user.
|
PaginateForums scans over the available forums for a user.
|
||||||
|
|
||||||
|
@ -77,8 +85,15 @@ Parameters:
|
||||||
- userID: of who is looking
|
- userID: of who is looking
|
||||||
- categories: optional, filter within categories
|
- categories: optional, filter within categories
|
||||||
- pager
|
- pager
|
||||||
|
|
||||||
|
The pager Sort accepts a couple of custom values for more advanced sorting:
|
||||||
|
|
||||||
|
- by_latest: recently updated posts
|
||||||
|
- by_threads: thread count
|
||||||
|
- by_posts: post count
|
||||||
|
- by_users: user count
|
||||||
*/
|
*/
|
||||||
func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Forum, error) {
|
func PaginateForums(user *User, categories []string, search *Search, subscribed bool, pager *Pagination) ([]*Forum, error) {
|
||||||
var (
|
var (
|
||||||
fs = []*Forum{}
|
fs = []*Forum{}
|
||||||
query = (&Forum{}).Preload()
|
query = (&Forum{}).Preload()
|
||||||
|
@ -96,9 +111,55 @@ func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Foru
|
||||||
wheres = append(wheres, "explicit = false")
|
wheres = append(wheres, "explicit = false")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide circle forums if the user isn't in the circle.
|
// Hide private forums except for admins and approved users.
|
||||||
if !user.IsInnerCircle() {
|
if !user.IsAdmin {
|
||||||
wheres = append(wheres, "inner_circle is not true")
|
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?
|
// 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 = query.Order(pager.Sort)
|
||||||
query.Model(&Forum{}).Count(&pager.Total)
|
query.Model(&Forum{}).Count(&pager.Total)
|
||||||
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
|
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
|
||||||
|
@ -116,20 +214,44 @@ func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Foru
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaginateOwnedForums returns forums the user owns (or all forums to admins).
|
// PaginateOwnedForums returns forums the user owns (or all forums to admins).
|
||||||
func PaginateOwnedForums(userID uint64, isAdmin bool, pager *Pagination) ([]*Forum, error) {
|
func PaginateOwnedForums(userID uint64, isAdmin bool, categories []string, search *Search, pager *Pagination) ([]*Forum, error) {
|
||||||
var (
|
var (
|
||||||
fs = []*Forum{}
|
fs = []*Forum{}
|
||||||
query = (&Forum{}).Preload()
|
query = (&Forum{}).Preload()
|
||||||
|
wheres = []string{}
|
||||||
|
placeholders = []interface{}{}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Users see only their owned forums.
|
||||||
if !isAdmin {
|
if !isAdmin {
|
||||||
query = query.Where(
|
wheres = append(wheres, "owner_id = ?")
|
||||||
"owner_id = ?",
|
placeholders = append(placeholders, userID)
|
||||||
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)
|
query.Model(&Forum{}).Count(&pager.Total)
|
||||||
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
|
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
|
||||||
return fs, result.Error
|
return fs, result.Error
|
||||||
|
@ -159,6 +281,15 @@ func CategorizeForums(fs []*Forum, categories []string) []*CategorizedForum {
|
||||||
idxMap = map[string]int{}
|
idxMap = map[string]int{}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Forum Browse page: we are not grouping by categories but still need at least one.
|
||||||
|
if len(categories) == 0 {
|
||||||
|
return []*CategorizedForum{
|
||||||
|
{
|
||||||
|
Forums: fs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize the result set.
|
// Initialize the result set.
|
||||||
for i, category := range categories {
|
for i, category := range categories {
|
||||||
result = append(result, &CategorizedForum{
|
result = append(result, &CategorizedForum{
|
||||||
|
|
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
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -20,13 +22,19 @@ type RecentPost struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaginateRecentPosts returns all of the comments on a forum paginated.
|
// PaginateRecentPosts returns all of the comments on a forum paginated.
|
||||||
func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]*RecentPost, error) {
|
func PaginateRecentPosts(user *User, categories []string, subscribed, allComments bool, pager *Pagination) ([]*RecentPost, error) {
|
||||||
var (
|
var (
|
||||||
result = []*RecentPost{}
|
result = []*RecentPost{}
|
||||||
query = (&Comment{}).Preload()
|
|
||||||
blockedUserIDs = BlockedUserIDs(user)
|
blockedUserIDs = BlockedUserIDs(user)
|
||||||
wheres = []string{"table_name = 'threads'"}
|
|
||||||
|
// Separate the WHERE clauses that involve forums/threads from the ones
|
||||||
|
// that involve comments. Rationale: if the user is getting a de-duplicated
|
||||||
|
// thread view, we'll end up running two queries - one to get all threads and
|
||||||
|
// another to get the latest comments, and the WHERE clauses need to be separate.
|
||||||
|
wheres = []string{}
|
||||||
placeholders = []interface{}{}
|
placeholders = []interface{}{}
|
||||||
|
comment_wheres = []string{"table_name = 'threads'"}
|
||||||
|
comment_ph = []interface{}{}
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(categories) > 0 {
|
if len(categories) > 0 {
|
||||||
|
@ -39,19 +47,47 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
|
||||||
wheres = append(wheres, "forums.explicit = false")
|
wheres = append(wheres, "forums.explicit = false")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Circle membership.
|
// Private forums.
|
||||||
if !user.IsInnerCircle() {
|
if !user.IsAdmin {
|
||||||
wheres = append(wheres, "forums.inner_circle is not true")
|
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?
|
// Blocked users?
|
||||||
if len(blockedUserIDs) > 0 {
|
if len(blockedUserIDs) > 0 {
|
||||||
wheres = append(wheres, "comments.user_id NOT IN ?")
|
comment_wheres = append(comment_wheres, "comments.user_id NOT IN ?")
|
||||||
placeholders = append(placeholders, blockedUserIDs)
|
comment_ph = append(comment_ph, blockedUserIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't show comments from banned or disabled accounts.
|
// Don't show comments from banned or disabled accounts.
|
||||||
wheres = append(wheres, `
|
comment_wheres = append(comment_wheres, `
|
||||||
EXISTS (
|
EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM users
|
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.
|
// Get the page of recent forum comment IDs of all time.
|
||||||
type scanner struct {
|
var scan NewestForumPostsScanner
|
||||||
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")
|
|
||||||
|
|
||||||
// Get the total for the pager and scan the page of ID sets.
|
// Deduplicate forum threads: if one thread is BLOWING UP with replies, we should only
|
||||||
query.Model(&Comment{}).Count(&pager.Total)
|
// mention the thread once and show the newest comment so it doesn't spam the whole page.
|
||||||
query = query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&scan)
|
if config.Current.Database.IsPostgres && !allComments {
|
||||||
if query.Error != nil {
|
// Note: only Postgres supports this function (SELECT DISTINCT ON).
|
||||||
return nil, query.Error
|
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.
|
// 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 {
|
if f, ok := forums[rc.ForumID]; ok {
|
||||||
rc.Forum = f
|
rc.Forum = f
|
||||||
}
|
}
|
||||||
|
@ -192,3 +230,140 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewestForumPosts collects the IDs of the latest forum posts.
|
||||||
|
type NewestForumPosts struct {
|
||||||
|
CommentID uint64
|
||||||
|
ThreadID *uint64
|
||||||
|
ForumID *uint64
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewestForumPostsScanner []NewestForumPosts
|
||||||
|
|
||||||
|
// ScanLatestForumCommentsAll returns a scan of Newest forum posts containing ALL comments, which may
|
||||||
|
// include runs of 'duplicate' forum threads if a given thread was commented on rapidly. This is the classic
|
||||||
|
// 'Newest' tab behavior, showing just ALL forum comments by newest.
|
||||||
|
func ScanLatestForumCommentsAll(wheres, comment_wheres []string, placeholders, comment_ph []interface{}, pager *Pagination) (NewestForumPostsScanner, error) {
|
||||||
|
var scan NewestForumPostsScanner
|
||||||
|
|
||||||
|
// This one is all one joined query so join the wheres/placeholders.
|
||||||
|
wheres = append(wheres, comment_wheres...)
|
||||||
|
placeholders = append(placeholders, comment_ph...)
|
||||||
|
|
||||||
|
// SQLite/non-Postgres doesn't support DISTINCT ON, this is the old query which
|
||||||
|
// shows objectively all comments and a popular thread may dominate the page.
|
||||||
|
query := DB.Table("comments").Select(
|
||||||
|
`comments.id AS comment_id,
|
||||||
|
threads.id AS thread_id,
|
||||||
|
forums.id AS forum_id,
|
||||||
|
comments.updated_at AS updated_at`,
|
||||||
|
).Joins(
|
||||||
|
"LEFT OUTER JOIN threads ON (table_name = 'threads' AND table_id = threads.id)",
|
||||||
|
).Joins(
|
||||||
|
"LEFT OUTER JOIN forums ON (threads.forum_id = forums.id)",
|
||||||
|
).Where(
|
||||||
|
strings.Join(wheres, " AND "),
|
||||||
|
placeholders...,
|
||||||
|
).Order("comments.updated_at desc")
|
||||||
|
query.Model(&Comment{}).Count(&pager.Total)
|
||||||
|
|
||||||
|
// Execute the query.
|
||||||
|
query = query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&scan)
|
||||||
|
return scan, query.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanLatestForumCommentsPerThread returns a scan of Newest forum posts, deduplicated by thread.
|
||||||
|
// Each thread ID will only appear once in the result, paired with the newest comment in that
|
||||||
|
// thread.
|
||||||
|
func ScanLatestForumCommentsPerThread(wheres, comment_wheres []string, placeholders, comment_ph []interface{}, pager *Pagination) (NewestForumPostsScanner, error) {
|
||||||
|
var (
|
||||||
|
result NewestForumPostsScanner
|
||||||
|
threadIDs = []uint64{}
|
||||||
|
|
||||||
|
// Query for ALL thread IDs (in forums the user can see).
|
||||||
|
query = DB.Table(
|
||||||
|
"threads",
|
||||||
|
).Select(`
|
||||||
|
DISTINCT ON (threads.id)
|
||||||
|
threads.forum_id,
|
||||||
|
threads.id AS thread_id,
|
||||||
|
threads.updated_at AS updated_at
|
||||||
|
`).Joins(
|
||||||
|
"JOIN forums ON (threads.forum_id = forums.id)",
|
||||||
|
).Where(
|
||||||
|
strings.Join(wheres, " AND "),
|
||||||
|
placeholders...,
|
||||||
|
).Order(
|
||||||
|
"threads.id",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
query = query.Find(&result)
|
||||||
|
if query.Error != nil {
|
||||||
|
return result, query.Error
|
||||||
|
}
|
||||||
|
pager.Total = int64(len(result))
|
||||||
|
|
||||||
|
// Reorder the result by timestamp.
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
return result[i].UpdatedAt.After(result[j].UpdatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Subslice the result per the user's pagination setting.
|
||||||
|
var (
|
||||||
|
start = pager.GetOffset()
|
||||||
|
stop = start + pager.PerPage
|
||||||
|
)
|
||||||
|
if start > len(result) {
|
||||||
|
return NewestForumPostsScanner{}, nil
|
||||||
|
} else if stop > len(result) {
|
||||||
|
stop = len(result)
|
||||||
|
}
|
||||||
|
result = result[start:stop]
|
||||||
|
|
||||||
|
// Map the thread IDs to their result row.
|
||||||
|
var threadMap = map[uint64]int{}
|
||||||
|
for i, row := range result {
|
||||||
|
threadIDs = append(threadIDs, *row.ThreadID)
|
||||||
|
threadMap[*row.ThreadID] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
// With these thread IDs, select the newest comments.
|
||||||
|
type scanner struct {
|
||||||
|
ThreadID uint64
|
||||||
|
CommentID uint64
|
||||||
|
}
|
||||||
|
var scan []scanner
|
||||||
|
err := DB.Table(
|
||||||
|
"comments",
|
||||||
|
).Select(
|
||||||
|
"table_id AS thread_id, id AS comment_id",
|
||||||
|
).Where(
|
||||||
|
`table_name='threads' AND table_id IN ?
|
||||||
|
AND updated_at = (SELECT MAX(updated_at)
|
||||||
|
FROM comments c2
|
||||||
|
WHERE c2.table_name=comments.table_name
|
||||||
|
AND c2.table_id=comments.table_id
|
||||||
|
)`,
|
||||||
|
threadIDs,
|
||||||
|
).Where(
|
||||||
|
strings.Join(comment_wheres, " AND "),
|
||||||
|
comment_ph...,
|
||||||
|
).Order(
|
||||||
|
"updated_at desc",
|
||||||
|
).Scan(&scan)
|
||||||
|
if err.Error != nil {
|
||||||
|
log.Error("Getting most recent post IDs: %s", err.Error)
|
||||||
|
return result, err.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the comment IDs back in.
|
||||||
|
for _, row := range scan {
|
||||||
|
if idx, ok := threadMap[row.ThreadID]; ok {
|
||||||
|
result[idx].CommentID = row.CommentID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, query.Error
|
||||||
|
}
|
||||||
|
|
|
@ -81,7 +81,7 @@ type ForumSearchFilters struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchForum searches the forum.
|
// 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 (
|
var (
|
||||||
coms = []*Comment{}
|
coms = []*Comment{}
|
||||||
query = (&Comment{}).Preload()
|
query = (&Comment{}).Preload()
|
||||||
|
@ -90,14 +90,19 @@ func SearchForum(user *User, search *Search, filters ForumSearchFilters, pager *
|
||||||
placeholders = []interface{}{}
|
placeholders = []interface{}{}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if len(categories) > 0 {
|
||||||
|
wheres = append(wheres, "category IN ?")
|
||||||
|
placeholders = append(placeholders, categories)
|
||||||
|
}
|
||||||
|
|
||||||
// Hide explicit forum if user hasn't opted into it.
|
// Hide explicit forum if user hasn't opted into it.
|
||||||
if !user.Explicit && !user.IsAdmin {
|
if !user.Explicit && !user.IsAdmin {
|
||||||
wheres = append(wheres, "forums.explicit = false")
|
wheres = append(wheres, "forums.explicit = false")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Circle membership.
|
// Private forums.
|
||||||
if !user.IsInnerCircle() {
|
if !user.IsAdmin {
|
||||||
wheres = append(wheres, "forums.inner_circle is not true")
|
wheres = append(wheres, "forums.private is not true")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blocked users?
|
// Blocked users?
|
||||||
|
|
|
@ -230,7 +230,6 @@ func (ts ForumStatsMap) generateRecentPosts(IDs []uint64) {
|
||||||
"comments",
|
"comments",
|
||||||
).Select(
|
).Select(
|
||||||
"table_id AS thread_id, id AS comment_id",
|
"table_id AS thread_id, id AS comment_id",
|
||||||
// "forum_id, id AS thread_id, updated_at",
|
|
||||||
).Where(
|
).Where(
|
||||||
`table_name='threads' AND table_id IN ?
|
`table_name='threads' AND table_id IN ?
|
||||||
AND updated_at = (SELECT MAX(updated_at)
|
AND updated_at = (SELECT MAX(updated_at)
|
||||||
|
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Friend table.
|
// Friend table.
|
||||||
|
@ -17,7 +16,7 @@ type Friend struct {
|
||||||
Approved bool `gorm:"index"`
|
Approved bool `gorm:"index"`
|
||||||
Ignored bool
|
Ignored bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time `gorm:"index"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddFriend sends a friend request or accepts one if there was already a pending one.
|
// AddFriend sends a friend request or accepts one if there was already a pending one.
|
||||||
|
@ -249,11 +248,19 @@ func FriendIDsInCircleAreExplicit(userId uint64) []uint64 {
|
||||||
|
|
||||||
// CountFriendRequests gets a count of pending requests for the user.
|
// CountFriendRequests gets a count of pending requests for the user.
|
||||||
func CountFriendRequests(userID uint64) (int64, error) {
|
func CountFriendRequests(userID uint64) (int64, error) {
|
||||||
var 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(
|
result := DB.Where(
|
||||||
"target_user_id = ? AND approved = ? AND ignored IS NOT true",
|
strings.Join(wheres, " AND "),
|
||||||
userID,
|
placeholders...,
|
||||||
false,
|
|
||||||
).Model(&Friend{}).Count(&count)
|
).Model(&Friend{}).Count(&count)
|
||||||
return count, result.Error
|
return count, result.Error
|
||||||
}
|
}
|
||||||
|
@ -262,7 +269,7 @@ func CountFriendRequests(userID uint64) (int64, error) {
|
||||||
func CountIgnoredFriendRequests(userID uint64) (int64, error) {
|
func CountIgnoredFriendRequests(userID uint64) (int64, error) {
|
||||||
var count int64
|
var count int64
|
||||||
result := DB.Where(
|
result := DB.Where(
|
||||||
"target_user_id = ? AND approved = ? AND ignored = ?",
|
"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,
|
userID,
|
||||||
false,
|
false,
|
||||||
true,
|
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) {
|
func PaginateFriends(user *User, requests bool, sent bool, ignored bool, pager *Pagination) ([]*User, error) {
|
||||||
// We paginate over the Friend table.
|
// We paginate over the Friend table.
|
||||||
var (
|
var (
|
||||||
fs = []*Friend{}
|
fs = []*Friend{}
|
||||||
userIDs = []uint64{}
|
userIDs = []uint64{}
|
||||||
query *gorm.DB
|
blockedUserIDs = BlockedUserIDs(user)
|
||||||
|
wheres = []string{}
|
||||||
|
placeholders = []interface{}{}
|
||||||
|
query = DB.Model(&Friend{})
|
||||||
)
|
)
|
||||||
|
|
||||||
if requests && sent && ignored {
|
if requests && sent && ignored {
|
||||||
return nil, errors.New("requests and sent are mutually exclusive options, use one or neither")
|
return nil, errors.New("requests and sent are mutually exclusive options, use one or neither")
|
||||||
}
|
}
|
||||||
|
|
||||||
if requests {
|
// Don't show our blocked users in the result.
|
||||||
query = DB.Where(
|
if len(blockedUserIDs) > 0 {
|
||||||
"target_user_id = ? AND approved = ? AND ignored IS NOT true",
|
wheres = append(wheres, "target_user_id NOT IN ?")
|
||||||
user.ID, false,
|
placeholders = append(placeholders, blockedUserIDs)
|
||||||
)
|
|
||||||
} 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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
query.Model(&Friend{}).Count(&pager.Total)
|
||||||
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
|
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
|
@ -446,6 +492,27 @@ func RemoveFriend(sourceUserID, targetUserID uint64) error {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RevokeFriendPhotoNotifications removes notifications about newly uploaded friends photos
|
||||||
|
// that were sent to your former friends, when you remove their friendship.
|
||||||
|
//
|
||||||
|
// For example: if I unfriend you, all your past notifications that showed my friends-only photos should
|
||||||
|
// be revoked so that you can't see them anymore.
|
||||||
|
//
|
||||||
|
// Notifications about friend photos are revoked going in both directions.
|
||||||
|
func RevokeFriendPhotoNotifications(currentUser, other *User) error {
|
||||||
|
// Gather the IDs of all their friends-only photos to nuke notifications for.
|
||||||
|
allPhotoIDs, err := AllFriendsOnlyPhotoIDs(currentUser, other)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if len(allPhotoIDs) == 0 {
|
||||||
|
// Nothing to do.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("RevokeFriendPhotoNotifications(%s): forget about friend photo uploads for user %s on photo IDs: %v", currentUser.Username, other.Username, allPhotoIDs)
|
||||||
|
return RemoveSpecificNotificationBulk([]*User{currentUser, other}, NotificationNewPhoto, "photos", allPhotoIDs)
|
||||||
|
}
|
||||||
|
|
||||||
// Save photo.
|
// Save photo.
|
||||||
func (f *Friend) Save() error {
|
func (f *Friend) Save() error {
|
||||||
result := DB.Save(f)
|
result := DB.Save(f)
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user