Compare commits

..

1 Commits

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

View File

@ -34,6 +34,19 @@ create extension postgis;
If you get errors like "Type geography not found" from Postgres when
running distance based searches, this is the likely culprit.
### Face Detection
Fedora: `dnf install python3 python3-opencv opencv-data`
Debian: `apt install python3-opencv opencv-data`
If you get an error like:
> facedetect: error: cannot load HAAR_FRONTALFACE_ALT2 from /usr/share/opencv/haarcascades/haarcascade_frontalface_alt2.xml
Check whether the correct path on disk is actually /usr/share/opencv4 instead of /usr/share/opencv.
One solution then is to symlink the path correctly.
## Building the App
This app is written in Go: [go.dev](https://go.dev). You can probably

View File

@ -10,6 +10,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/models/backfill"
"code.nonshy.com/nonshy/website/pkg/models/exporting"
"code.nonshy.com/nonshy/website/pkg/photo"
"code.nonshy.com/nonshy/website/pkg/redis"
"code.nonshy.com/nonshy/website/pkg/worker"
"github.com/urfave/cli/v2"
@ -229,6 +230,9 @@ func initdb(c *cli.Context) {
// Auto-migrate the DB.
models.AutoMigrate()
// Initialize FaceScore face detection.
photo.InitFaceScore()
}
func initcache(c *cli.Context) {

82
go.mod
View File

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

310
go.sum
View File

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

View File

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

View File

@ -35,51 +35,18 @@ const (
ScopeUserInsight = "admin.user.insights"
ScopeUserImpersonate = "admin.user.impersonate"
ScopeUserBan = "admin.user.ban"
ScopeUserDelete = "admin.user.delete"
ScopeUserPromote = "admin.user.promote"
// Other admin views
ScopeFeedbackAndReports = "admin.feedback"
ScopeChangeLog = "admin.changelog"
ScopeUserNotes = "admin.user.notes"
ScopeUserDelete = "admin.user.delete"
// Admins with this scope can not be blocked by users.
ScopeUnblockable = "admin.unblockable"
// Special scope to mark an admin automagically in the Inner Circle
ScopeIsInnerCircle = "admin.override.inner-circle"
// The global wildcard scope gets all available permissions.
ScopeSuperuser = "*"
)
// Friendly description for each scope.
var AdminScopeDescriptions = map[string]string{
ScopeChatModerator: "Have operator controls in the chat room (can mark cameras as explicit, or kick/ban people from chat).",
ScopeForumModerator: "Ability to moderate the forum (edit or delete posts).",
ScopePhotoModerator: "Ability to moderate photo galleries (can see all private or friends-only photos, and edit or delete them).",
ScopeCircleModerator: "Ability to remove members from the inner circle.",
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.)",
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.",
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.",
ScopeIsInnerCircle: "This admin is automatically part of the inner circle.",
ScopeSuperuser: "This admin has access to ALL admin features on the website.",
}
// Number of expected scopes for unit test and validation.
const QuantityAdminScopes = 20
const QuantityAdminScopes = 16
// The specially named Superusers group.
const AdminGroupSuperusers = "Superusers"
@ -96,20 +63,12 @@ func ListAdminScopes() []string {
ScopeCertificationView,
ScopeForumAdmin,
ScopeAdminScopeAdmin,
ScopeMaintenance,
ScopeUserInsight,
ScopeUserImpersonate,
ScopeUserBan,
ScopeUserDelete,
ScopeUserPromote,
ScopeFeedbackAndReports,
ScopeChangeLog,
ScopeUserNotes,
ScopeUnblockable,
ScopeIsInnerCircle,
}
}
func AdminScopeDescription(scope string) string {
return AdminScopeDescriptions[scope]
}

View File

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

View File

@ -66,13 +66,6 @@ const (
ContactRateLimitCooldownAt = 1
ContactRateLimitCooldown = 2 * time.Minute
// "Mark Explicit" rate limit to curb a mischievous user just bulk marking the
// whole gallery as explicit.
MarkExplicitRateLimitWindow = 1 * time.Hour
MarkExplicitRateLimit = 20 // 10 failed MarkExplicit attempts = locked for full hour
MarkExplicitRateLimitCooldownAt = 10 // 10 photos in an hour, start throttling.
MarkExplicitRateLimitCooldown = time.Minute
// How frequently to refresh LastLoginAt since sessions are long-lived.
LastLoginAtCooldown = time.Hour
@ -81,7 +74,7 @@ const (
)
var (
UsernameRegexp = regexp.MustCompile(`^[a-z0-9_.-]{3,32}$`)
UsernameRegexp = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
ReservedUsernames = []string{
"admin",
"admins",
@ -101,7 +94,6 @@ var (
const (
MaxPhotoWidth = 1280
ProfilePhotoWidth = 512
AltTextMaxLength = 5000
// Quotas for uploaded photos.
PhotoQuotaUncertified = 6

View File

@ -1,7 +1,5 @@
package config
import "regexp"
// Various hard-coded enums such as choice of gender, sexuality, relationship status etc.
var (
MaritalStatus = []string{
@ -34,8 +32,6 @@ var (
"Gay",
"Bisexual",
"Bicurious",
"Pansexual",
"Asexual",
}
HereFor = []string{
@ -107,12 +103,6 @@ var (
"Photo Boards",
"Anything Goes",
}
// Keywords that appear in a DM that make it likely spam.
DirectMessageSpamKeywords = []*regexp.Regexp{
regexp.MustCompile(`\b(telegram|whats\s*app|signal|kik|session)\b`),
regexp.MustCompile(`https?://(t.me|join.skype.com|zoom.us|whereby.com|meet.jit.si|wa.me)`),
}
)
// ContactUs choices for the subject drop-down.

View File

@ -15,8 +15,7 @@ var (
PageSizePrivatePhotoGrantees = 12
PageSizeAdminCertification = 20
PageSizeAdminFeedback = 20
PageSizeAdminFeedbackNotesPage = 5 // feedback on User Notes page
PageSizeChangeLog = 20
PageSizeAdminFeedbackNotesPage = 5 // feedback on User Notes page
PageSizeAdminUserNotes = 10 // other users' notes
PageSizeSiteGallery = 16
PageSizeUserGallery = 16

View File

@ -14,7 +14,7 @@ import (
// Version of the config format - when new fields are added, it will attempt
// to write the settings.toml to disk so new defaults populate.
var currentVersion = 2
var currentVersion = 3
// Current loaded settings.json
var Current = DefaultVariable()
@ -31,6 +31,7 @@ type Variable struct {
BareRTC BareRTC
Maintenance Maintenance
Encryption Encryption
FaceScore FaceScore
UseXForwardedFor bool
}
@ -52,6 +53,9 @@ func DefaultVariable() Variable {
SQLite: "database.sqlite",
Postgres: "host=localhost user=nonshy password=nonshy dbname=nonshy port=5679 sslmode=disable TimeZone=America/Los_Angeles",
},
FaceScore: FaceScore{
CascadeFile: "/path/to/cascade/file",
},
CronAPIKey: uuid.New().String(),
}
}
@ -165,3 +169,9 @@ type Maintenance struct {
type Encryption struct {
AESKey []byte
}
// FaceScore settings for face detection in photos via esimov/pigo.
type FaceScore struct {
Enabled bool
CascadeFile string
}

View File

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

View File

@ -4,8 +4,6 @@ import (
"net/http"
"strings"
"code.nonshy.com/nonshy/website/pkg/chat"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
@ -43,14 +41,6 @@ func Deactivate() http.HandlerFunc {
session.LogoutUser(w, r)
session.Flash(w, r, "Your account has been deactivated and you are now logged out. If you wish to re-activate your account, sign in again with your username and password.")
templates.Redirect(w, "/")
// Maybe kick them from chat if this deletion makes them into a Shy Account.
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
}
// Log the change.
models.LogEvent(currentUser, nil, models.ChangeLogLifecycle, "users", currentUser.ID, "Deactivated their account.")
return
}
@ -88,8 +78,5 @@ func Reactivate() http.HandlerFunc {
session.Flash(w, r, "Welcome back! Your account has been reactivated.")
templates.Redirect(w, "/")
// Log the change.
models.LogEvent(currentUser, nil, models.ChangeLogLifecycle, "users", currentUser.ID, "Reactivated their account.")
})
}

View File

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

View File

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

View File

@ -83,7 +83,7 @@ func InviteCircle() http.HandlerFunc {
log.Info("InnerCircle: %s adds %s to the inner circle", currentUser.Username, user.Username)
templates.Redirect(w, "/u/"+user.Username+"/photos")
templates.Redirect(w, "/photo/u/"+user.Username)
return
}

View File

@ -3,6 +3,7 @@ package account
import (
"net/http"
"net/url"
"regexp"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/middleware"
@ -12,12 +13,18 @@ import (
"code.nonshy.com/nonshy/website/pkg/worker"
)
var ProfileRegexp = regexp.MustCompile(`^/u/([^@]+?)$`)
// User profile page (/u/username)
func Profile() http.HandlerFunc {
tmpl := templates.Must("account/profile.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the username out of the URL parameters.
var username = r.PathValue("username")
var username string
m := ProfileRegexp.FindStringSubmatch(r.URL.Path)
if m != nil {
username = m[1]
}
// Find this user.
user, err := models.FindUser(username)
@ -79,9 +86,14 @@ func Profile() http.HandlerFunc {
var isSelf = currentUser.ID == user.ID
// 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)
// Banned or disabled? Only admin can view then.
if user.Status != models.UserStatusActive && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
return
}
// Is either one blocking?
if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
return
}
@ -102,14 +114,20 @@ func Profile() http.HandlerFunc {
}
vars := map[string]interface{}{
"User": user,
"LikeMap": likeMap,
"IsFriend": isFriend,
"IsPrivate": isPrivate,
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
"NoteCount": models.CountNotesAboutUser(currentUser, user),
"FriendCount": models.CountFriends(user.ID),
"OnChat": worker.GetChatStatistics().IsOnline(user.Username),
"User": user,
"LikeMap": likeMap,
"IsFriend": isFriend,
"IsPrivate": isPrivate,
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
"NoteCount": models.CountNotesAboutUser(currentUser, user),
"FriendCount": models.CountFriends(user.ID),
"ForumThreadCount": models.CountThreadsByUser(user),
"ForumReplyCount": models.CountCommentsByUser(user, "threads"),
"PhotoCommentCount": models.CountCommentsByUser(user, "photos"),
"CommentsReceivedCount": models.CountCommentsReceived(user),
"LikesGivenCount": models.CountLikesGiven(user),
"LikesReceivedCount": models.CountLikesReceived(user),
"OnChat": worker.GetChatStatistics().IsOnline(user.Username),
// Details on who likes their profile page.
"LikeExample": likeExample,

View File

@ -96,14 +96,12 @@ func Search() http.HandlerFunc {
InnerCircle: isCertified == "circle",
ShyAccounts: isCertified == "shy",
IsBanned: isCertified == "banned",
IsDisabled: isCertified == "disabled",
IsAdmin: isCertified == "admin",
Friends: friendSearch,
AgeMin: ageMin,
AgeMax: ageMax,
}, pager)
if err != nil {
session.FlashError(w, r, "An error has occurred: %s.", err)
session.FlashError(w, r, "Couldn't search users: %s", err)
}
// Who's Nearby feature, get some data.

View File

@ -9,7 +9,6 @@ import (
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/chat"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/geoip"
"code.nonshy.com/nonshy/website/pkg/log"
@ -17,10 +16,8 @@ import (
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/redis"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/spam"
"code.nonshy.com/nonshy/website/pkg/templates"
"code.nonshy.com/nonshy/website/pkg/utility"
"code.nonshy.com/nonshy/website/pkg/worker"
"github.com/google/uuid"
)
@ -53,10 +50,6 @@ func Settings() http.HandlerFunc {
return
}
// Is the user currently in the chat room? Gate username changes when so.
var isOnChat = worker.GetChatStatistics().IsOnline(user.Username)
vars["OnChat"] = isOnChat
// URL hashtag to redirect to
var hashtag string
@ -115,15 +108,7 @@ func Settings() http.HandlerFunc {
// Set profile attributes.
for _, attr := range config.ProfileFields {
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)
user.SetProfileField(attr, r.PostFormValue(attr))
}
// "Looking For" checkbox list.
@ -182,7 +167,6 @@ func Settings() http.HandlerFunc {
for _, field := range []string{
"hero-text-dark",
"card-lightness",
"website-theme",
} {
value := r.PostFormValue(field)
user.SetProfileField(field, value)
@ -309,91 +293,32 @@ func Settings() http.HandlerFunc {
case "settings":
hashtag = "#account"
var (
oldPassword = r.PostFormValue("old_password")
changeEmail = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_email")))
changeUsername = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_username")))
password1 = strings.TrimSpace(r.PostFormValue("new_password"))
password2 = strings.TrimSpace(r.PostFormValue("new_password2"))
oldPassword = r.PostFormValue("old_password")
changeEmail = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_email")))
password1 = strings.TrimSpace(r.PostFormValue("new_password"))
password2 = strings.TrimSpace(r.PostFormValue("new_password2"))
)
// Their old password is needed to make any changes to their account.
if err := user.CheckPassword(oldPassword); err != nil {
session.FlashError(w, r, "Could not make changes to your account settings as the 'current password' you entered was incorrect.")
templates.Redirect(w, r.URL.Path+hashtag)
templates.Redirect(w, r.URL.Path)
return
}
// Changing their username?
if changeUsername != user.Username {
// Not if they are in the chat room!
if isOnChat {
session.FlashError(w, r, "Your username could not be changed right now because you are logged into the chat room. Please exit the chat room, wait a minute, and try your request again.")
templates.Redirect(w, r.URL.Path+hashtag)
return
}
// Check if the new name is OK.
if err := models.IsValidUsername(changeUsername); err != nil {
session.FlashError(w, r, "Could not change your username: %s", err.Error())
templates.Redirect(w, r.URL.Path+hashtag)
return
}
// Clear their history on the chat room.
go func(username string) {
log.Error("Change of username, clear chat history for old name %s", username)
i, err := chat.EraseChatHistory(username)
if err != nil {
log.Error("EraseChatHistory(%s): %s", username, err)
return
}
session.Flash(w, r, "Notice: due to your recent change in username, your direct message history on the Chat Room has been reset. %d message(s) had been removed.", i)
}(user.Username)
// Set their name.
origUsername := user.Username
user.Username = changeUsername
if err := user.Save(); err != nil {
session.FlashError(w, r, "Error saving your new username: %s", err)
} else {
session.Flash(w, r, "Your username has been updated to: %s", user.Username)
// Notify the admin about this to keep tabs if someone is acting strangely
// with too-frequent username changes.
fb := &models.Feedback{
Intent: "report",
Subject: "Change of username",
UserID: user.ID,
TableName: "users",
TableID: user.ID,
Message: fmt.Sprintf(
"A user has modified their username on their profile page!\n\n"+
"* Original: %s\n* Updated: %s",
origUsername, changeUsername,
),
}
// Save the feedback.
if err := models.CreateFeedback(fb); err != nil {
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
}
}
}
// Changing their email?
if changeEmail != user.Email {
// Validate the email.
if _, err := nm.ParseAddress(changeEmail); err != nil {
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
templates.Redirect(w, r.URL.Path+hashtag)
templates.Redirect(w, r.URL.Path)
return
}
// Email must not already exist.
if _, err := models.FindUser(changeEmail); err == nil {
session.FlashError(w, r, "That email address is already in use.")
templates.Redirect(w, r.URL.Path+hashtag)
templates.Redirect(w, r.URL.Path)
return
}
@ -405,7 +330,7 @@ func Settings() http.HandlerFunc {
}
if err := redis.Set(fmt.Sprintf(config.ChangeEmailRedisKey, token.Token), token, config.SignupTokenExpires); err != nil {
session.FlashError(w, r, "Failed to create change email token: %s", err)
templates.Redirect(w, r.URL.Path+hashtag)
templates.Redirect(w, r.URL.Path)
return
}
@ -449,11 +374,6 @@ func Settings() http.HandlerFunc {
session.FlashError(w, r, "Unknown POST intent value. Please try again.")
}
// Maybe kick them from the chat room if they had become a Shy Account.
if _, err := chat.MaybeDisconnectUser(user); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
}
templates.Redirect(w, r.URL.Path+hashtag+".")
return
}

View File

@ -60,6 +60,7 @@ func Signup() http.HandlerFunc {
}
var token SignupToken
log.Info("SignupToken: %s", tokenStr)
if tokenStr != "" {
// Validate it.
if err := redis.Get(fmt.Sprintf(config.SignupTokenRedisKey, tokenStr), &token); err != nil || token.Token != tokenStr {
@ -85,9 +86,6 @@ func Signup() http.HandlerFunc {
password = strings.TrimSpace(r.PostFormValue("password"))
password2 = strings.TrimSpace(r.PostFormValue("password2"))
dob = r.PostFormValue("dob")
// Validation errors but still show the form again.
hasError bool
)
// Don't let them sneakily change their verified email address on us.
@ -97,10 +95,27 @@ func Signup() http.HandlerFunc {
return
}
// Reserved username check.
for _, cmp := range config.ReservedUsernames {
if username == cmp {
session.FlashError(w, r, "That username is reserved, please choose a different username.")
templates.Redirect(w, r.URL.Path+"?token="+tokenStr)
return
}
}
// Cache username in case of passwd validation errors.
vars["Email"] = email
vars["Username"] = username
// Is the app not configured to send email?
if !config.Current.Mail.Enabled {
session.FlashError(w, r, "This app is not configured to send email so you can not sign up at this time. "+
"Please contact the website administrator about this issue!")
templates.Redirect(w, r.URL.Path)
return
}
// Validate the email.
if _, err := nm.ParseAddress(email); err != nil {
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
@ -148,16 +163,6 @@ func Signup() http.HandlerFunc {
session.FlashError(w, r, "Error creating a link to send you: %s", err)
}
// Is the app not configured to send email?
if !config.Current.Mail.Enabled && !config.SkipEmailVerification {
// Log the signup token for local dev.
log.Error("Signup: the app is not configured to send email. To continue, visit the URL: /signup?token=%s", token.Token)
session.FlashError(w, r, "This app is not configured to send email so you can not sign up at this time. "+
"Please contact the website administrator about this issue!")
templates.Redirect(w, r.URL.Path)
return
}
err := mail.Send(mail.Message{
To: email,
Subject: "Verify your e-mail address",
@ -172,10 +177,6 @@ func Signup() http.HandlerFunc {
}
session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email)
// Reminder to check their spam folder too (Gmail users)
session.Flash(w, r, "If you don't see the confirmation e-mail, check in case it went to your spam folder.")
templates.Redirect(w, r.URL.Path)
return
}
@ -204,6 +205,7 @@ func Signup() http.HandlerFunc {
}
// Full sign-up step (w/ email verification token), validate more things.
var hasError bool
if len(password) < 3 {
session.FlashError(w, r, "Please enter a password longer than 3 characters.")
hasError = true
@ -212,9 +214,8 @@ func Signup() http.HandlerFunc {
hasError = true
}
// Validate the username is OK: well formatted, not reserved, not existing.
if err := models.IsValidUsername(username); err != nil {
session.FlashError(w, r, err.Error())
if !config.UsernameRegexp.MatchString(username) {
session.FlashError(w, r, "Your username must consist of only numbers, letters, - . and be 3-32 characters.")
hasError = true
}

View File

@ -3,6 +3,7 @@ package account
import (
"net/http"
"net/url"
"regexp"
"strconv"
"code.nonshy.com/nonshy/website/pkg/config"
@ -13,12 +14,18 @@ import (
"code.nonshy.com/nonshy/website/pkg/templates"
)
var NotesURLRegexp = regexp.MustCompile(`^/notes/u/([^@]+?)$`)
// User notes page (/notes/u/username)
func UserNotes() http.HandlerFunc {
tmpl := templates.Must("account/user_notes.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the username out of the URL parameters.
var username = r.PathValue("username")
var username string
m := NotesURLRegexp.FindStringSubmatch(r.URL.Path)
if m != nil {
username = m[1]
}
// Find this user.
user, err := models.FindUser(username)
@ -201,7 +208,7 @@ func MyNotes() http.HandlerFunc {
}
// Admin notes?
if adminNotes && !currentUser.HasAdminScope(config.ScopeUserNotes) {
if adminNotes && !currentUser.IsAdmin {
adminNotes = false
}

View File

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

View File

@ -52,7 +52,7 @@ func Feedback() http.HandlerFunc {
} else {
// If this is an "inner circle removal" report, go to their gallery and filter pics by Public.
if fb.Intent == "report.circle" {
templates.Redirect(w, "/u/"+user.Username+"/photos?visibility=public")
templates.Redirect(w, "/photo/u/"+user.Username+"?visibility=public")
} else {
templates.Redirect(w, "/u/"+user.Username)
}

View File

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

View File

@ -1,14 +1,11 @@
package admin
import (
"fmt"
"net/http"
"strconv"
"strings"
"code.nonshy.com/nonshy/website/pkg/chat"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/models/deletion"
"code.nonshy.com/nonshy/website/pkg/session"
@ -27,14 +24,6 @@ func MarkPhotoExplicit() http.HandlerFunc {
next = "/"
}
// Get current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Failed to get current user: %s", err)
templates.Redirect(w, "/")
return
}
if idInt, err := strconv.Atoi(r.FormValue("photo_id")); err == nil {
photoID = uint64(idInt)
} else {
@ -57,12 +46,6 @@ func MarkPhotoExplicit() http.HandlerFunc {
} 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)
})
}
@ -158,14 +141,6 @@ func UserActions() http.HandlerFunc {
user.Save()
session.Flash(w, r, "User ban status updated!")
templates.Redirect(w, "/u/"+user.Username)
// Maybe kick them from chat room now.
if _, err := chat.MaybeDisconnectUser(user); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
}
// Log the change.
models.LogEvent(user, currentUser, models.ChangeLogBanned, "users", currentUser.ID, fmt.Sprintf("User ban status updated to: %s", status))
return
}
case "promote":
@ -182,9 +157,6 @@ func UserActions() http.HandlerFunc {
user.Save()
session.Flash(w, r, "User admin status updated!")
templates.Redirect(w, "/u/"+user.Username)
// Log the change.
models.LogEvent(user, currentUser, models.ChangeLogAdmin, "users", currentUser.ID, fmt.Sprintf("User admin status updated to: %s", action))
return
}
case "delete":
@ -202,14 +174,6 @@ func UserActions() http.HandlerFunc {
session.Flash(w, r, "User has been deleted!")
}
templates.Redirect(w, "/admin")
// Kick them from the chat room if they are online.
if _, err := chat.DisconnectUserNow(user, "You have been signed out of chat because your account has been deleted."); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
}
// Log the change.
models.LogDeleted(nil, currentUser, "users", user.ID, fmt.Sprintf("Username %s has been deleted by an admin.", user.Username), nil)
return
}
default:

View File

@ -96,28 +96,25 @@ func Likes() http.HandlerFunc {
case "photos":
if photo, err := models.GetPhoto(tableID); err == nil {
if user, err := models.GetUser(photo.UserID); err == nil {
// Safety check: if the current user should not see this picture, they can not "Like" it.
// Example: you unfriended them but they still had the image on their old browser page.
var unallowed bool
if currentUser.ID != user.ID {
// Admin safety check: in case the admin clicked 'Like' on a friends-only or private
// picture they shouldn't have been expected to see, do not log a like.
if currentUser.IsAdmin && currentUser.ID != user.ID {
if (photo.Visibility == models.PhotoFriends && !models.AreFriends(user.ID, currentUser.ID)) ||
(photo.Visibility == models.PhotoPrivate && !models.IsPrivateUnlocked(user.ID, currentUser.ID)) {
unallowed = true
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) {
unallowed = true
}
if unallowed {
SendJSON(w, http.StatusForbidden, Response{
Error: "You are not allowed to like that photo.",
})
return
}
targetUser = user
}
} else {
@ -178,7 +175,7 @@ func Likes() http.HandlerFunc {
}
// Remove the target's notification about this like.
models.RemoveSpecificNotificationAboutUser(targetUser.ID, currentUser.ID, models.NotificationLike, req.TableName, tableID)
models.RemoveSpecificNotification(targetUser.ID, models.NotificationLike, req.TableName, tableID)
} else {
if err := models.AddLike(currentUser, req.TableName, tableID); err != nil {
SendJSON(w, http.StatusBadRequest, Response{

View File

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

View File

@ -97,9 +97,6 @@ func BlockUser() http.HandlerFunc {
session.FlashError(w, r, "Couldn't unblock this user: %s.", err)
} else {
session.Flash(w, r, "You have removed %s from your block list.", user.Username)
// Log the change.
models.LogDeleted(currentUser, nil, "blocks", user.ID, "Unblocked user "+user.Username+".", nil)
}
templates.Redirect(w, "/users/blocked")
return
@ -142,9 +139,6 @@ func BlockUser() http.HandlerFunc {
session.FlashError(w, r, "Couldn't block this user: %s.", err)
} else {
session.Flash(w, r, "You have added %s to your block list.", user.Username)
// Log the change.
models.LogCreated(currentUser, "blocks", user.ID, "Blocks user "+user.Username+".")
}
// Sync the block to the BareRTC chat server now, in case either user is currently online.

View File

@ -153,10 +153,6 @@ func Landing() http.HandlerFunc {
log.Error("SendBlocklist: %s", err)
}
// Mark them as online immediately: so e.g. on the Change Username screen we leave no window
// of time where they can exist in chat but change their name on the site.
worker.GetChatStatistics().SetOnlineNow(currentUser.Username)
// Redirect them to the chat room.
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss)
return

View File

@ -117,9 +117,6 @@ func PostComment() http.HandlerFunc {
session.FlashError(w, r, "Error deleting your commenting: %s", err)
} else {
session.Flash(w, r, "Your comment has been deleted.")
// Log the change.
models.LogDeleted(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, "Deleted a comment.", comment)
}
templates.Redirect(w, fromURL)
return
@ -154,9 +151,6 @@ func PostComment() http.HandlerFunc {
session.FlashError(w, r, "Couldn't save comment: %s", err)
} else {
session.Flash(w, r, "Comment updated!")
// Log the change.
models.LogUpdated(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, "Updated a comment.\n\n---\n\n"+comment.Message, nil)
}
templates.Redirect(w, fromURL)
return
@ -174,9 +168,6 @@ func PostComment() http.HandlerFunc {
session.Flash(w, r, "Comment added!")
templates.Redirect(w, fromURL)
// Log the change.
models.LogCreated(currentUser, "comments", comment.ID, "Posted a new comment.\n\n---\n\n"+message)
// Notify the recipient of the comment.
if notifyUser != nil && notifyUser.ID != currentUser.ID && !notifyUser.NotificationOptOut(config.NotificationOptOutComments) {
notif := &models.Notification{

View File

@ -1,7 +1,6 @@
package forum
import (
"fmt"
"net/http"
"strconv"
"strings"
@ -65,29 +64,16 @@ func AddEdit() http.HandlerFunc {
isPrivileged = r.PostFormValue("privileged") == "true"
isPermitPhotos = r.PostFormValue("permit_photos") == "true"
isInnerCircle = r.PostFormValue("inner_circle") == "true"
isPrivate = r.PostFormValue("private") == "true"
)
// Sanity check admin-only settings.
if !currentUser.IsAdmin {
isPrivileged = false
isPermitPhotos = false
isPrivate = false
}
// Were we editing an existing forum?
if forum != nil {
diffs := []models.FieldDiff{
models.NewFieldDiff("Title", forum.Title, title),
models.NewFieldDiff("Description", forum.Description, description),
models.NewFieldDiff("Category", forum.Category, category),
models.NewFieldDiff("Explicit", forum.Explicit, isExplicit),
models.NewFieldDiff("Privileged", forum.Privileged, isPrivileged),
models.NewFieldDiff("PermitPhotos", forum.PermitPhotos, isPermitPhotos),
models.NewFieldDiff("InnerCircle", forum.InnerCircle, isInnerCircle),
models.NewFieldDiff("Private", forum.Private, isPrivate),
}
forum.Title = title
forum.Description = description
forum.Category = category
@ -95,15 +81,11 @@ func AddEdit() http.HandlerFunc {
forum.Privileged = isPrivileged
forum.PermitPhotos = isPermitPhotos
forum.InnerCircle = isInnerCircle
forum.Private = isPrivate
// Save it.
if err := forum.Save(); err == nil {
session.Flash(w, r, "Forum has been updated!")
templates.Redirect(w, "/forum/admin")
// Log the change.
models.LogUpdated(currentUser, nil, "forums", forum.ID, "Updated the forum's settings.", diffs)
return
} else {
session.FlashError(w, r, "Error saving the forum: %s", err)
@ -132,36 +114,11 @@ func AddEdit() http.HandlerFunc {
Privileged: isPrivileged,
PermitPhotos: isPermitPhotos,
InnerCircle: isInnerCircle,
Private: isPrivate,
}
if err := models.CreateForum(forum); err == nil {
session.Flash(w, r, "The forum has been created!")
templates.Redirect(w, "/forum/admin")
// Log the change.
models.LogCreated(currentUser, "forums", forum.ID, fmt.Sprintf(
"Created a new forum.\n\n"+
"* Category: %s\n"+
"* Title: %s\n"+
"* Fragment: %s\n"+
"* Description: %s\n"+
"* Explicit: %v\n"+
"* Privileged: %v\n"+
"* Photos: %v\n"+
"* Inner Circle: %v\n"+
"* Private: %v",
forum.Category,
forum.Title,
forum.Fragment,
forum.Description,
forum.Explicit,
forum.Privileged,
forum.PermitPhotos,
forum.InnerCircle,
forum.Private,
))
return
} else {
session.FlashError(w, r, "Error creating the forum: %s", err)

View File

@ -4,6 +4,7 @@ import (
"net/http"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
@ -15,16 +16,21 @@ func Forum() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the path parameters
var (
fragment = r.PathValue("fragment")
forum *models.Forum
forum *models.Forum
)
// Look up the forum by its fragment.
if found, err := models.ForumByFragment(fragment); err != nil {
if m := ForumPathRegexp.FindStringSubmatch(r.URL.Path); m == nil {
log.Error("Regexp failed to parse: %s", r.URL.Path)
templates.NotFoundPage(w, r)
return
} else {
forum = found
// Look up the forum itself.
if found, err := models.ForumByFragment(m[1]); err != nil {
templates.NotFoundPage(w, r)
return
} else {
forum = found
}
}
// Get the current user.
@ -41,12 +47,6 @@ func Forum() http.HandlerFunc {
return
}
// Is it a private forum?
if forum.Private && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
return
}
// Get the pinned threads.
pinned, err := models.PinnedThreads(forum)
if err != nil {

View File

@ -17,6 +17,17 @@ var (
FragmentRegexp = regexp.MustCompile(
fmt.Sprintf(`^(%s)$`, FragmentPattern),
)
// Forum path parameters.
ForumPathRegexp = regexp.MustCompile(
fmt.Sprintf(`^/f/(%s)`, FragmentPattern),
)
ForumPostRegexp = regexp.MustCompile(
fmt.Sprintf(`^/f/(%s)/(post)`, FragmentPattern),
)
ForumThreadRegexp = regexp.MustCompile(
fmt.Sprintf(`^/f/(%s)/(thread)/(\d+)`, FragmentPattern),
)
)
// Landing page for forums.

View File

@ -15,7 +15,6 @@ import (
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/photo"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/spam"
"code.nonshy.com/nonshy/website/pkg/templates"
)
@ -162,11 +161,6 @@ func NewPost() http.HandlerFunc {
session.FlashError(w, r, "Error deleting your post: %s", err)
} else {
session.Flash(w, r, "Your post has been deleted.")
// Log the change.
models.LogDeleted(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, fmt.Sprintf(
"Deleted a forum comment on thread %d forum /f/%s", thread.ID, forum.Fragment,
), comment)
}
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return
@ -184,19 +178,6 @@ func NewPost() http.HandlerFunc {
// Submitting the form.
if r.Method == http.MethodPost {
// Look for spammy links to video sites or things.
if err := spam.DetectSpamMessage(title + message); err != nil {
session.FlashError(w, r, err.Error())
if thread != nil {
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
} else if forum != nil {
templates.Redirect(w, fmt.Sprintf("/f/%s", forum.Fragment))
} else {
templates.Redirect(w, "/forum")
}
return
}
// Polls: parse form parameters into a neat list of answers.
pollExpires, _ = strconv.Atoi(r.FormValue("poll_expires"))
var distinctPollChoices = map[string]interface{}{}
@ -334,14 +315,6 @@ func NewPost() http.HandlerFunc {
session.FlashError(w, r, "Couldn't save comment: %s", err)
} else {
session.Flash(w, r, "Comment updated!")
// Log the change.
models.LogUpdated(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, fmt.Sprintf(
"Edited their comment on thread %d (in /f/%s):\n\n%s",
thread.ID,
forum.Fragment,
message,
), nil)
}
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return
@ -354,13 +327,6 @@ func NewPost() http.HandlerFunc {
} else {
session.Flash(w, r, "Reply added to the thread!")
// Log the change.
models.LogCreated(currentUser, "comments", reply.ID, fmt.Sprintf(
"Commented on thread %d:\n\n%s",
thread.ID,
message,
))
// If we're attaching a photo, link it to this reply CommentID.
if commentPhoto != nil {
commentPhoto.CommentID = reply.ID
@ -392,7 +358,7 @@ func NewPost() http.HandlerFunc {
TableName: "threads",
TableID: thread.ID,
Message: message,
Link: fmt.Sprintf("/go/comment?id=%d", reply.ID),
Link: fmt.Sprintf("/forum/thread/%d%s#p%d", thread.ID, queryString, reply.ID),
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create thread reply notification for subscriber %d: %s", userID, err)
@ -462,18 +428,6 @@ func NewPost() http.HandlerFunc {
}
}
// Log the change.
models.LogCreated(currentUser, "threads", thread.ID, fmt.Sprintf(
"Started a new forum thread on forum /f/%s (%s)\n\n"+
"* Has poll? %v\n"+
"* Title: %s\n\n%s",
forum.Fragment,
forum.Title,
isPoll,
thread.Title,
message,
))
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return
}

View File

@ -14,11 +14,6 @@ import (
func Newest() http.HandlerFunc {
tmpl := templates.Must("forum/newest.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query parameters.
var (
allComments = r.FormValue("all") == "true"
)
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
@ -34,7 +29,7 @@ func Newest() http.HandlerFunc {
}
pager.ParsePage(r)
posts, err := models.PaginateRecentPosts(currentUser, config.ForumCategories, allComments, pager)
posts, err := models.PaginateRecentPosts(currentUser, config.ForumCategories, pager)
if err != nil {
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
templates.Redirect(w, "/")
@ -55,7 +50,6 @@ func Newest() http.HandlerFunc {
"Pager": pager,
"RecentPosts": posts,
"PhotoMap": photos,
"AllComments": allComments,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -2,6 +2,7 @@ package forum
import (
"net/http"
"regexp"
"strconv"
"code.nonshy.com/nonshy/website/pkg/config"
@ -11,22 +12,24 @@ import (
"code.nonshy.com/nonshy/website/pkg/templates"
)
var ThreadPathRegexp = regexp.MustCompile(`^/forum/thread/(\d+)$`)
// Thread view for the comment thread body of a forum post.
func Thread() http.HandlerFunc {
tmpl := templates.Must("forum/thread.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the path parameters
var (
idStr = r.PathValue("id")
forum *models.Forum
thread *models.Thread
)
if idStr == "" {
if m := ThreadPathRegexp.FindStringSubmatch(r.URL.Path); m == nil {
log.Error("Regexp failed to parse: %s", r.URL.Path)
templates.NotFoundPage(w, r)
return
} else {
if threadID, err := strconv.Atoi(idStr); err != nil {
if threadID, err := strconv.Atoi(m[1]); err != nil {
session.FlashError(w, r, "Invalid thread ID in the address bar.")
templates.Redirect(w, "/forum")
return
@ -57,12 +60,6 @@ func Thread() http.HandlerFunc {
return
}
// Is it a private forum?
if forum.Private && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
return
}
// Ping the view count on this thread.
if err := thread.View(currentUser.ID); err != nil {
log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err)

View File

@ -60,11 +60,6 @@ func AddFriend() http.HandlerFunc {
return
}
// Revoke any friends-only photo notifications they had received before.
if err := models.RevokeFriendPhotoNotifications(currentUser, user); err != nil {
log.Error("Couldn't revoke friend photo notifications between %s and %s: %s", currentUser.Username, user.Username, err)
}
var message string
if verdict == "reject" {
message = fmt.Sprintf("Friend request from %s has been rejected.", username)
@ -75,12 +70,6 @@ func AddFriend() http.HandlerFunc {
session.Flash(w, r, message)
if verdict == "reject" {
templates.Redirect(w, "/friends?view=requests")
// Log the change.
models.LogDeleted(currentUser, nil, "friends", user.ID, "Rejected friend request from "+user.Username+".", nil)
} else {
// Log the change.
models.LogDeleted(currentUser, nil, "friends", user.ID, "Removed friendship with "+user.Username+".", nil)
}
templates.Redirect(w, "/friends")
return
@ -91,9 +80,6 @@ func AddFriend() http.HandlerFunc {
session.Flash(w, r, "You have ignored the friend request from %s.", username)
}
templates.Redirect(w, "/friends")
// Log the change.
models.LogUpdated(currentUser, nil, "friends", user.ID, "Ignored the friend request from "+user.Username+".", nil)
return
} else {
// Post the friend request.
@ -115,13 +101,7 @@ func AddFriend() http.HandlerFunc {
session.Flash(w, r, "You accepted the friend request from %s!", username)
templates.Redirect(w, "/friends?view=requests")
// Log the change.
models.LogUpdated(currentUser, nil, "friends", user.ID, "Accepted friend request from "+user.Username+".", nil)
return
} else {
// Log the change.
models.LogCreated(currentUser, "friends", user.ID, "Sent a friend request to "+user.Username+".")
}
session.Flash(w, r, "Friend request sent!")
}

View File

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

View File

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

View File

@ -2,13 +2,11 @@ package photo
import (
"bytes"
"fmt"
"io"
"net/http"
"path/filepath"
"strconv"
"code.nonshy.com/nonshy/website/pkg/chat"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/geoip"
"code.nonshy.com/nonshy/website/pkg/log"
@ -89,14 +87,6 @@ func Certification() http.HandlerFunc {
session.FlashError(w, r, "Error saving your User data: %s", err)
}
// Log the change.
models.LogDeleted(currentUser, nil, "certification_photos", currentUser.ID, "Removed their certification photo.", cert)
// Kick them from the chat room if they are online.
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
}
session.Flash(w, r, "Your certification photo has been deleted.")
templates.Redirect(w, r.URL.Path)
return
@ -148,11 +138,6 @@ func Certification() http.HandlerFunc {
session.FlashError(w, r, "Error saving your User data: %s", err)
}
// Kick them from the chat room if they are online.
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
}
// Notify the admin email to check out this photo.
if err := mail.Send(mail.Message{
To: config.Current.AdminEmail,
@ -166,26 +151,6 @@ func Certification() http.HandlerFunc {
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.")
templates.Redirect(w, r.URL.Path)
return
@ -332,14 +297,6 @@ func AdminCertification() http.HandlerFunc {
user.Certified = false
user.Save()
// Log the change.
models.LogEvent(user, currentUser, models.ChangeLogRejected, "certification_photos", user.ID, "Rejected the certification photo with comment: "+comment)
// Kick them from the chat room if they are online.
if _, err := chat.MaybeDisconnectUser(user); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
}
// Did we silently ignore it?
if comment == "(ignore)" {
session.FlashError(w, r, "The certification photo was ignored with no comment, and will not notify the sender.")
@ -410,9 +367,6 @@ func AdminCertification() http.HandlerFunc {
session.FlashError(w, r, "Note: failed to email user about the approval: %s", err)
}
// Log the change.
models.LogEvent(user, currentUser, models.ChangeLogApproved, "certification_photos", user.ID, "Approved the certification photo.")
session.Flash(w, r, "Certification photo approved!")
default:
session.FlashError(w, r, "Unsupported verdict option: %s", verdict)

View File

@ -5,9 +5,7 @@ import (
"net/http"
"path/filepath"
"strconv"
"strings"
"code.nonshy.com/nonshy/website/pkg/chat"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
@ -44,10 +42,6 @@ func Edit() http.HandlerFunc {
return
}
// In case an admin is editing this photo: remember the HTTP request current user,
// before the currentUser may be set to the photo's owner below.
var requestUser = currentUser
// Do we have permission for this photo?
if photo.UserID != currentUser.ID {
if !currentUser.IsAdmin {
@ -72,8 +66,7 @@ func Edit() http.HandlerFunc {
// Are we saving the changes?
if r.Method == http.MethodPost {
var (
caption = strings.TrimSpace(r.FormValue("caption"))
altText = strings.TrimSpace(r.FormValue("alt_text"))
caption = r.FormValue("caption")
isExplicit = r.FormValue("explicit") == "true"
isGallery = r.FormValue("gallery") == "true"
visibility = models.PhotoVisibility(r.FormValue("visibility"))
@ -82,30 +75,20 @@ func Edit() http.HandlerFunc {
setProfilePic = r.FormValue("intent") == "profile-pic"
crop = pphoto.ParseCropCoords(r.FormValue("crop"))
// Re-compute the face score (admin only)
recomputeFaceScore = r.FormValue("recompute_face_score") == "true" && currentUser.IsAdmin
// Are we GOING private or changing to Inner Circle?
goingPrivate = visibility == models.PhotoPrivate && visibility != photo.Visibility
goingCircle = visibility == models.PhotoInnerCircle && visibility != photo.Visibility
)
if len(altText) > config.AltTextMaxLength {
altText = altText[:config.AltTextMaxLength]
}
// Respect the Site Gallery throttle in case the user is messing around.
if SiteGalleryThrottled {
isGallery = false
}
// Diff for the ChangeLog.
diffs := []models.FieldDiff{
models.NewFieldDiff("Caption", photo.Caption, caption),
models.NewFieldDiff("Explicit", photo.Explicit, isExplicit),
models.NewFieldDiff("Gallery", photo.Gallery, isGallery),
models.NewFieldDiff("Visibility", photo.Visibility, visibility),
}
photo.Caption = caption
photo.AltText = altText
photo.Explicit = isExplicit
photo.Gallery = isGallery
photo.Visibility = visibility
@ -136,6 +119,19 @@ func Edit() http.HandlerFunc {
setProfilePic = false
}
log.Error("SAVING PHOTO: %+v", photo)
// Are we re-computing the face score?
if recomputeFaceScore {
score, err := pphoto.ComputeFaceScore(pphoto.DiskPath(photo.Filename))
if err != nil {
session.FlashError(w, r, "Face score: %s", err)
} else {
session.Flash(w, r, "Face score recomputed!")
photo.FaceScore = &score
}
}
if err := photo.Save(); err != nil {
session.FlashError(w, r, "Couldn't save photo: %s", err)
}
@ -152,14 +148,6 @@ func Edit() http.HandlerFunc {
// Flash success.
session.Flash(w, r, "Photo settings updated!")
// Log the change.
models.LogUpdated(currentUser, requestUser, "photos", photo.ID, "Updated the photo's settings.", diffs)
// Maybe kick them from the chat if this photo save makes them a Shy Account.
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
}
// If this picture has moved to Private, revoke any notification we gave about it before.
if goingPrivate || goingCircle {
log.Info("The picture is GOING PRIVATE (to %s), revoke any notifications about it", photo.Visibility)
@ -167,7 +155,7 @@ func Edit() http.HandlerFunc {
}
// Return the user to their gallery.
templates.Redirect(w, "/u/"+currentUser.Username+"/photos")
templates.Redirect(w, "/photo/u/"+currentUser.Username)
return
}
@ -216,10 +204,6 @@ func Delete() http.HandlerFunc {
return
}
// In case an admin is editing this photo: remember the HTTP request current user,
// before the currentUser may be set to the photo's owner below.
var requestUser = currentUser
// Do we have permission for this photo?
if photo.UserID != currentUser.ID {
if !currentUser.IsAdmin {
@ -238,13 +222,6 @@ func Delete() http.HandlerFunc {
}
}
// Inner circle warning: if this deletion would drop them below the 5 public picture
// threshold, warn them they will be removed from the circle if they continue.
var innerCircleWarning bool
if currentUser.IsInnerCircle() && photo.Visibility == models.PhotoPublic && models.CountPublicPhotos(currentUser.ID) <= 5 {
innerCircleWarning = true
}
// Confirm deletion?
if r.Method == http.MethodPost {
confirm := r.PostFormValue("confirm") == "true"
@ -285,33 +262,15 @@ func Delete() http.HandlerFunc {
return
}
// Log the change.
models.LogDeleted(currentUser, requestUser, "photos", photo.ID, "Deleted the photo.", photo)
// Remove them from the inner circle?
if innerCircleWarning {
if err := models.RemoveFromInnerCircle(currentUser); err != nil {
session.FlashError(w, r, "Couldn't remove from the inner circle: %s", err)
} else {
session.Flash(w, r, "You have been removed from the inner circle because your count of public photos has dropped below 5.")
}
}
session.Flash(w, r, "Photo deleted!")
// Maybe kick them from chat if this deletion makes them into a Shy Account.
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
}
// Return the user to their gallery.
templates.Redirect(w, "/u/"+currentUser.Username+"/photos")
templates.Redirect(w, "/photo/u/"+currentUser.Username)
return
}
var vars = map[string]interface{}{
"Photo": photo,
"InnerCircleWarning": innerCircleWarning,
"Photo": photo,
}
if err := tmpl.Execute(w, r, vars); err != nil {

View File

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

View File

@ -42,6 +42,8 @@ func Private() http.HandlerFunc {
return
}
log.Error("pager: %+v, len: %d", pager, len(users))
// Map reverse grantee statuses.
var GranteeMap interface{}
if isGrantee {
@ -143,7 +145,7 @@ func Share() http.HandlerFunc {
Type: models.NotificationPrivatePhoto,
TableName: "__private_photos",
TableID: currentUser.ID,
Link: fmt.Sprintf("/u/%s/photos?visibility=private", currentUser.Username),
Link: fmt.Sprintf("/photo/u/%s?visibility=private", currentUser.Username),
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create PrivatePhoto notification: %s", err)
@ -160,7 +162,7 @@ func Share() http.HandlerFunc {
models.RemoveSpecificNotification(user.ID, models.NotificationPrivatePhoto, "__private_photos", currentUser.ID)
// Revoke any "has uploaded a new private photo" notifications in this user's list.
if err := models.RevokePrivatePhotoNotifications(currentUser, user); err != nil {
if err := models.RevokePrivatePhotoNotifications(currentUser, &user.ID); err != nil {
log.Error("RevokePrivatePhotoNotifications(%s): %s", currentUser.Username, err)
}
return

View File

@ -64,7 +64,7 @@ func SiteGallery() http.HandlerFunc {
// They didn't post a "Whose photos" filter, restore it from their last saved default.
who = currentUser.GetProfileField("site_gallery_default")
}
if who != "friends" && who != "everybody" && who != "friends+private" && who != "likes" {
if who != "friends" && who != "everybody" && who != "friends+private" {
// Default Who setting should be Friends-only, unless you have no friends.
if myFriendCount > 0 {
who = "friends"
@ -94,7 +94,6 @@ func SiteGallery() http.HandlerFunc {
AdminView: adminView,
FriendsOnly: who == "friends",
IsShy: isShy || who == "friends+private",
MyLikes: who == "likes",
}, pager)
// Bulk load the users associated with these photos.

View File

@ -2,12 +2,10 @@ package photo
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
@ -61,8 +59,7 @@ func Upload() http.HandlerFunc {
// Are they POSTing?
if r.Method == http.MethodPost {
var (
caption = strings.TrimSpace(r.PostFormValue("caption"))
altText = strings.TrimSpace(r.PostFormValue("alt_text"))
caption = r.PostFormValue("caption")
isExplicit = r.PostFormValue("explicit") == "true"
visibility = r.PostFormValue("visibility")
isGallery = r.PostFormValue("gallery") == "true"
@ -76,14 +73,10 @@ func Upload() http.HandlerFunc {
isGallery = false
}
if len(altText) > config.AltTextMaxLength {
altText = altText[:config.AltTextMaxLength]
}
// Are they at quota already?
if photoCount >= photoQuota {
session.FlashError(w, r, "You have too many photos to upload a new one. Please delete a photo to make room for a new one.")
templates.Redirect(w, "/u/"+user.Username+"/photos")
templates.Redirect(w, "/photo/u/"+user.Username)
return
}
@ -141,7 +134,6 @@ func Upload() http.HandlerFunc {
Filename: filename,
CroppedFilename: cropFilename,
Caption: caption,
AltText: altText,
Visibility: models.PhotoVisibility(visibility),
Gallery: isGallery,
Explicit: isExplicit,
@ -167,24 +159,11 @@ func Upload() http.HandlerFunc {
user.Save()
}
// ChangeLog entry.
models.LogCreated(user, "photos", p.ID, fmt.Sprintf(
"Uploaded a new photo.\n\n"+
"* Caption: %s\n"+
"* Visibility: %s\n"+
"* Gallery: %v\n"+
"* Explicit: %v",
p.Caption,
p.Visibility,
p.Gallery,
p.Explicit,
))
// Notify all of our friends that we posted a new picture.
go notifyFriendsNewPhoto(p, user)
session.Flash(w, r, "Your photo has been uploaded successfully.")
templates.Redirect(w, "/u/"+user.Username+"/photos")
templates.Redirect(w, "/photo/u/"+user.Username)
return
}

View File

@ -2,6 +2,7 @@ package photo
import (
"net/http"
"regexp"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
@ -10,6 +11,8 @@ import (
"code.nonshy.com/nonshy/website/pkg/templates"
)
var UserPhotosRegexp = regexp.MustCompile(`^/photo/u/([^@]+?)$`)
// UserPhotos controller (/photo/u/:username) to view a user's gallery or manage if it's yourself.
func UserPhotos() http.HandlerFunc {
tmpl := templates.Must("photo/gallery.html")
@ -23,7 +26,6 @@ func UserPhotos() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query params.
var (
username = r.PathValue("username")
viewStyle = r.FormValue("view") // cards (default), full
// Search filters.
@ -52,6 +54,13 @@ func UserPhotos() http.HandlerFunc {
viewStyle = "cards"
}
// Parse the username out of the URL parameters.
var username string
m := UserPhotosRegexp.FindStringSubmatch(r.URL.Path)
if m != nil {
username = m[1]
}
// Find this user.
user, err := models.FindUser(username)
if err != nil {

View File

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

View File

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

View File

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

View File

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

View File

@ -16,9 +16,9 @@ type Comment struct {
TableName string `gorm:"index"`
TableID uint64 `gorm:"index"`
UserID uint64 `gorm:"index"`
User User `json:"-"`
User User
Message string
CreatedAt time.Time `gorm:"index"`
CreatedAt time.Time
UpdatedAt time.Time
}

View File

@ -89,16 +89,9 @@ func MapCommentPhotos(comments []*Comment) (CommentPhotoMap, error) {
)
for _, c := range comments {
if c == nil {
continue
}
IDs = append(IDs, c.ID)
}
if len(IDs) == 0 {
return result, nil
}
res := DB.Model(&CommentPhoto{}).Where("comment_id IN ?", IDs).Find(&ps)
if res.Error != nil {
return nil, res.Error

View File

@ -3,7 +3,6 @@ package deletion
import (
"fmt"
"code.nonshy.com/nonshy/website/pkg/chat"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/photo"
@ -13,17 +12,6 @@ import (
func DeleteUser(user *models.User) error {
log.Error("BEGIN DeleteUser(%d, %s)", user.ID, user.Username)
// Clear their history on the chat room.
go func() {
i, err := chat.EraseChatHistory(user.Username)
if err != nil {
log.Error("EraseChatHistory(%s): %s", user.Username, err)
return
}
log.Error("DeleteUser(%s): Cleared chat DMs history for user (%d messages erased)", user.Username, i)
}()
// Remove all linked tables and assets.
type remover struct {
Step string
@ -53,8 +41,6 @@ func DeleteUser(user *models.User) error {
{"Two Factor", DeleteTwoFactor},
{"Profile Fields", DeleteProfile},
{"User Notes", DeleteUserNotes},
{"Change Logs", DeleteChangeLogs},
{"IP Addresses", DeleteIPAddresses},
}
for _, item := range todo {
if err := item.Fn(user.ID); err != nil {
@ -341,23 +327,3 @@ func DeleteUserNotes(userID uint64) error {
).Delete(&models.UserNote{})
return result.Error
}
// DeleteChangeLogs scrubs data for deleting a user.
func DeleteChangeLogs(userID uint64) error {
log.Error("DeleteUser: DeleteChangeLogs(%d)", userID)
result := models.DB.Where(
"about_user_id = ?",
userID,
).Delete(&models.ChangeLog{})
return result.Error
}
// DeleteIPAddresses scrubs data for deleting a user.
func DeleteIPAddresses(userID uint64) error {
log.Error("DeleteUser: DeleteIPAddresses(%d)", userID)
result := models.DB.Where(
"user_id = ?",
userID,
).Delete(&models.IPAddress{})
return result.Error
}

View File

@ -40,9 +40,7 @@ func ExportModels(zw *zip.Writer, user *models.User) error {
// Note: AdminGroup info is eager-loaded in User export
{"UserLocation", ExportUserLocationTable},
{"UserNote", ExportUserNoteTable},
{"ChangeLog", ExportChangeLogTable},
{"TwoFactor", ExportTwoFactorTable},
{"IPAddress", ExportIPAddressTable},
}
for _, item := range todo {
log.Info("Exporting data model: %s", item.Step)
@ -385,21 +383,6 @@ func ExportUserNoteTable(zw *zip.Writer, user *models.User) error {
return ZipJson(zw, "user_notes.json", items)
}
func ExportChangeLogTable(zw *zip.Writer, user *models.User) error {
var (
items = []*models.ChangeLog{}
query = models.DB.Model(&models.ChangeLog{}).Where(
"about_user_id = ? OR admin_user_id = ?",
user.ID, user.ID,
).Find(&items)
)
if query.Error != nil {
return query.Error
}
return ZipJson(zw, "change_logs.json", items)
}
func ExportUserLocationTable(zw *zip.Writer, user *models.User) error {
var (
items = []*models.UserLocation{}
@ -429,18 +412,3 @@ func ExportTwoFactorTable(zw *zip.Writer, user *models.User) error {
return ZipJson(zw, "two_factor.json", items)
}
func ExportIPAddressTable(zw *zip.Writer, user *models.User) error {
var (
items = []*models.IPAddress{}
query = models.DB.Model(&models.IPAddress{}).Where(
"user_id = ?",
user.ID,
).Find(&items)
)
if query.Error != nil {
return query.Error
}
return ZipJson(zw, "ip_addresses.json", items)
}

View File

@ -20,8 +20,7 @@ type Forum struct {
Explicit bool `gorm:"index"`
Privileged bool
PermitPhotos bool
InnerCircle bool `gorm:"index"`
Private bool `gorm:"index"`
InnerCircle bool
CreatedAt time.Time
UpdatedAt time.Time
}
@ -102,11 +101,6 @@ func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Foru
wheres = append(wheres, "inner_circle is not true")
}
// Hide private forums except for admins.
if !user.IsAdmin {
wheres = append(wheres, "private is not true")
}
// Filters?
if len(wheres) > 0 {
query = query.Where(

View File

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

View File

@ -100,11 +100,6 @@ func SearchForum(user *User, search *Search, filters ForumSearchFilters, pager *
wheres = append(wheres, "forums.inner_circle is not true")
}
// Private forums.
if !user.IsAdmin {
wheres = append(wheres, "forums.private is not true")
}
// Blocked users?
if len(blockedUserIDs) > 0 {
wheres = append(wheres, "comments.user_id NOT IN ?")

View File

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

View File

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

View File

@ -1,74 +0,0 @@
package models
import (
"net/http"
"time"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/utility"
)
// IPAddress table to log which networks users have logged in from.
type IPAddress struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"index"`
IPAddress string `gorm:"index"`
NumberVisits uint64 // count of times their LastLoginAt pinged from this address
CreatedAt time.Time // first time seen
UpdatedAt time.Time // last time seen
}
// PingIPAddress logs or upserts the user's current IP address into the IPAddress table.
func PingIPAddress(r *http.Request, user *User, incrementVisit bool) error {
var (
addr = utility.IPAddress(r)
ip *IPAddress
)
// Have we seen it before?
ip, err := LoadUserIPAddress(user, addr)
if err != nil {
// Insert it.
log.Debug("User %s IP %s seen for the first time", user.Username, addr)
ip = &IPAddress{
UserID: user.ID,
IPAddress: addr,
CreatedAt: time.Now(),
}
result := DB.Create(ip)
if result.Error != nil {
return result.Error
}
}
// Are we refreshing the NumberVisits count? Note: this happens each
// time the main website will refresh the user LastLoginAt.
if incrementVisit || ip.NumberVisits == 0 {
ip.NumberVisits++
}
// Ping the update.
ip.UpdatedAt = time.Now()
return ip.Save()
}
func LoadUserIPAddress(user *User, ipAddr string) (*IPAddress, error) {
var ip = &IPAddress{}
var result = DB.Model(&IPAddress{}).Where(
"user_id = ? AND ip_address = ?",
user.ID, ipAddr,
).First(&ip)
return ip, result.Error
}
// Save photo.
func (ip *IPAddress) Save() error {
result := DB.Save(ip)
return result.Error
}
// Delete the DB entry.
func (ip *IPAddress) Delete() error {
return DB.Delete(ip).Error
}

View File

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

View File

@ -3,8 +3,6 @@ package models
import (
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
)
// Message table.
@ -229,19 +227,6 @@ func SendMessage(sourceUserID, targetUserID uint64, message string) (*Message, e
return m, result.Error
}
// IsLikelySpam checks if a DM message is likely to be spam so that the front-end can warn the recipient.
//
// This happens e.g. when the sender asks to switch to Telegram or WhatsApp.
func (m *Message) IsLikelySpam() bool {
body := strings.ToLower(m.Message)
for _, re := range config.DirectMessageSpamKeywords {
if idx := re.FindStringIndex(body); len(idx) > 0 {
return true
}
}
return false
}
// Save message.
func (m *Message) Save() error {
result := DB.Save(m)

View File

@ -31,6 +31,4 @@ func AutoMigrate() {
DB.AutoMigrate(&UserLocation{})
DB.AutoMigrate(&UserNote{})
DB.AutoMigrate(&TwoFactor{})
DB.AutoMigrate(&ChangeLog{})
DB.AutoMigrate(&IPAddress{})
}

View File

@ -1,8 +1,6 @@
package models
import (
"errors"
"fmt"
"strings"
"time"
@ -42,13 +40,13 @@ const (
NotificationAlsoPosted NotificationType = "also_posted" // forum replies
NotificationCertRejected NotificationType = "cert_rejected"
NotificationCertApproved NotificationType = "cert_approved"
NotificationPrivatePhoto NotificationType = "private_photo" // private photo grants
NotificationPrivatePhoto NotificationType = "private_photo"
NotificationNewPhoto NotificationType = "new_photo"
NotificationInnerCircle NotificationType = "inner_circle"
NotificationCustom NotificationType = "custom" // custom message pushed
)
// CreateNotification inserts a new notification into the database.
// CreateNotification
func CreateNotification(n *Notification) error {
// Insert via raw SQL query, reasoning:
// the AboutUser relationship has gorm do way too much work:
@ -101,33 +99,6 @@ func RemoveNotification(tableName string, tableID uint64) error {
return result.Error
}
// RemoveAlsoPostedNotification removes a 'has also posted' notification if the comment is later deleted.
//
// This is specialized for deleting replies to forum threads where subscribers were notified that the
// user has AlsoPosted on that thread. If the user deletes their comment, this specific notification
// needs to be revoked from people who received it before, so the head of their original comment is not
// leaked on their notifications page.
//
// These notifications have a Type=also_posted TableName=threads TableID=threads.ID with the only hard
// link to the specific comment on that thread being the hyperlink URL that goes to their comment.
func RemoveAlsoPostedNotification(thread *Thread, commentID uint64) error {
// Match the specific notification by its link URL.
var (
// Modern link URL ('/go/comment?id=1234' which finds the right page to see the comment)
newLink = fmt.Sprintf("/go/comment?id=%d", commentID)
// Legacy link URL ('/forum/thread/123?page=4#p456') which embeds the thread ID, an
// optional query string (page number) and the comment ID anchor.
legacyLink = fmt.Sprintf("/forum/thread/%d%%#p%d", thread.ID, commentID)
)
result := DB.Where(
"type = ? AND table_name = 'threads' AND table_id = ? AND (link = ? OR link LIKE ?)",
NotificationAlsoPosted, thread.ID, newLink, legacyLink,
).Delete(&Notification{})
return result.Error
}
// RemoveNotificationBulk about several table IDs, e.g. when bulk removing private photo upload
// notifications for everybody on the site.
func RemoveNotificationBulk(tableName string, tableIDs []uint64) error {
@ -148,32 +119,12 @@ func RemoveSpecificNotification(userID uint64, t NotificationType, tableName str
return result.Error
}
// RemoveSpecificNotificationAboutUser to remove a specific table_name/id notification about a user,
// e.g. when removing a like on a photo.
func RemoveSpecificNotificationAboutUser(userID, aboutUserID uint64, t NotificationType, tableName string, tableID uint64) error {
result := DB.Where(
"user_id = ? AND about_user_id = ? AND type = ? AND table_name = ? AND table_id = ?",
userID, aboutUserID, t, tableName, tableID,
).Delete(&Notification{})
return result.Error
}
// RemoveSpecificNotificationBulk can remove notifications about several TableIDs of the same type,
// e.g. to bulk remove new private photo upload notifications.
func RemoveSpecificNotificationBulk(users []*User, t NotificationType, tableName string, tableIDs []uint64) error {
var userIDs = []uint64{}
for _, user := range users {
userIDs = append(userIDs, user.ID)
}
if len(userIDs) == 0 {
// Nothing to do.
return errors.New("no user IDs given")
}
func RemoveSpecificNotificationBulk(userID uint64, t NotificationType, tableName string, tableIDs []uint64) error {
result := DB.Where(
"user_id IN ? AND type = ? AND table_name = ? AND table_id IN ?",
userIDs, t, tableName, tableIDs,
"user_id = ? AND type = ? AND table_name = ? AND table_id IN ?",
userID, t, tableName, tableIDs,
).Delete(&Notification{})
return result.Error
}
@ -232,7 +183,7 @@ func CountUnreadNotifications(user *User) (int64, error) {
}
// PaginateNotifications returns the user's notifications.
func PaginateNotifications(user *User, filters NotificationFilter, pager *Pagination) ([]*Notification, error) {
func PaginateNotifications(user *User, pager *Pagination) ([]*Notification, error) {
var (
ns = []*Notification{}
blockedUserIDs = BlockedUserIDs(user)
@ -260,12 +211,6 @@ func PaginateNotifications(user *User, filters NotificationFilter, pager *Pagina
)
`)
// Mix in notification type filters?
if w, ph, ok := filters.Query(); ok {
where = append(where, w)
placeholders = append(placeholders, ph)
}
query := (&Notification{}).Preload().Where(
strings.Join(where, " AND "),
placeholders...,

View File

@ -1,92 +0,0 @@
package models
import (
"net/http"
)
// NotificationFilter handles users filtering their notification list by category. It is populated
// from front-end checkboxes and translates to SQL filters for PaginateNotifications.
type NotificationFilter struct {
Likes bool `json:"likes"` // form field name
Comments bool `json:"comments"`
NewPhotos bool `json:"photos"`
AlsoCommented bool `json:"replies"` // also_comment and also_posted
PrivatePhoto bool `json:"private"`
Misc bool `json:"misc"` // friendship_approved, cert_approved, cert_rejected, inner_circle, custom
}
var defaultNotificationFilter = NotificationFilter{
Likes: true,
Comments: true,
NewPhotos: true,
AlsoCommented: true,
PrivatePhoto: true,
Misc: true,
}
// NewNotificationFilterFromForm creates a NotificationFilter struct parsed from an HTTP form.
func NewNotificationFilterFromForm(r *http.Request) NotificationFilter {
// Are these boxes checked in a frontend post?
var (
nf = NotificationFilter{
Likes: r.FormValue("likes") == "true",
Comments: r.FormValue("comments") == "true",
NewPhotos: r.FormValue("photos") == "true",
AlsoCommented: r.FormValue("replies") == "true",
PrivatePhoto: r.FormValue("private") == "true",
Misc: r.FormValue("misc") == "true",
}
)
// Default view or when no checkboxes were sent, all are true.
if nf.IsZero() {
return defaultNotificationFilter
}
return nf
}
// IsZero checks for an empty filter.
func (nf NotificationFilter) IsZero() bool {
return nf == NotificationFilter{}
}
// IsAll checks if all filters are checked.
func (nf NotificationFilter) IsAll() bool {
return nf == defaultNotificationFilter
}
// Query returns the SQL "WHERE" clause that applies the filters to the Notifications query.
//
// If no filters should be added, ok returns false.
func (nf NotificationFilter) Query() (where string, placeholders []interface{}, ok bool) {
if nf.IsAll() {
return "", nil, false
}
var (
// Notification types to include.
types = []interface{}{}
)
// Translate
if nf.Likes {
types = append(types, NotificationLike)
}
if nf.Comments {
types = append(types, NotificationComment)
}
if nf.NewPhotos {
types = append(types, NotificationNewPhoto)
}
if nf.AlsoCommented {
types = append(types, NotificationAlsoCommented, NotificationAlsoPosted)
}
if nf.PrivatePhoto {
types = append(types, NotificationPrivatePhoto)
}
if nf.Misc {
types = append(types, NotificationFriendApproved, NotificationCertApproved, NotificationCertRejected, NotificationCustom)
}
return "type IN ?", types, true
}

View File

@ -19,12 +19,12 @@ type Photo struct {
CroppedFilename string // if cropped, e.g. for profile photo
Filesize int64
Caption string
AltText string
Flagged bool // photo has been reported by the community
Visibility PhotoVisibility `gorm:"index"`
Gallery bool `gorm:"index"` // photo appears in the public gallery (if public)
Explicit bool `gorm:"index"` // is an explicit photo
CreatedAt time.Time `gorm:"index"`
Flagged bool // photo has been reported by the community
Visibility PhotoVisibility
Gallery bool // photo appears in the public gallery (if public)
Explicit bool // is an explicit photo
FaceScore *float64 // face detection score (best)
CreatedAt time.Time
UpdatedAt time.Time
}
@ -78,7 +78,6 @@ func CreatePhoto(tmpl Photo) (*Photo, error) {
CroppedFilename: tmpl.CroppedFilename,
Filesize: tmpl.Filesize,
Caption: tmpl.Caption,
AltText: tmpl.AltText,
Visibility: tmpl.Visibility,
Gallery: tmpl.Gallery,
Explicit: tmpl.Explicit,
@ -214,34 +213,6 @@ func CountRecentGalleryPhotos(user *User, duration time.Duration) (count int64)
return
}
// AllFriendsOnlyPhotoIDs returns the listing of all friends-only photo IDs belonging to the user(s) given.
func AllFriendsOnlyPhotoIDs(users ...*User) ([]uint64, error) {
var userIDs = []uint64{}
for _, user := range users {
userIDs = append(userIDs, user.ID)
}
if len(userIDs) == 0 {
return nil, errors.New("no user IDs given")
}
var photoIDs = []uint64{}
err := DB.Table(
"photos",
).Select(
"photos.id AS id",
).Where(
"user_id IN ? AND visibility = ?",
userIDs, PhotoFriends,
).Scan(&photoIDs)
if err.Error != nil {
return photoIDs, fmt.Errorf("AllFriendsOnlyPhotoIDs(%+v): %s", userIDs, err.Error)
}
return photoIDs, nil
}
// CountPhotosICanSee returns the number of photos on an account which can be seen by the given viewer.
func CountPhotosICanSee(user *User, viewer *User) int64 {
// Visibility filters to query by.
@ -474,7 +445,6 @@ type Gallery struct {
AdminView bool // Show all images
IsShy bool // Current user is like a Shy Account (or: show self/friends and private photo grants only)
FriendsOnly bool // Only show self/friends instead of everybody's pics
MyLikes bool // Filter to photos I have liked
}
/*
@ -578,20 +548,6 @@ func PaginateGalleryPhotos(user *User, conf Gallery, pager *Pagination) ([]*Phot
wheres = append(wheres, "gallery = ?")
placeholders = append(placeholders, true)
// Filter by photos the user has liked.
if conf.MyLikes {
wheres = append(wheres, `
EXISTS (
SELECT 1
FROM likes
WHERE user_id = ?
AND table_name = 'photos'
AND table_id = photos.id
)
`)
placeholders = append(placeholders, user.ID)
}
// Filter blocked users.
if len(blocklist) > 0 {
wheres = append(wheres, "user_id NOT IN ?")

View File

@ -61,7 +61,7 @@ func RevokePrivatePhotosAll(sourceUserID uint64) error {
// RevokePrivatePhotoNotifications removes notifications about newly uploaded private photos
// that were sent to one (or multiple) members when the user revokes their access later. Pass
// a nil fromUserID to revoke the photo upload notifications from ALL users.
func RevokePrivatePhotoNotifications(currentUser, fromUser *User) error {
func RevokePrivatePhotoNotifications(currentUser *User, fromUserID *uint64) error {
// Gather the IDs of all our private photos to nuke notifications for.
photoIDs, err := currentUser.AllPrivatePhotoIDs()
if err != nil {
@ -72,12 +72,12 @@ func RevokePrivatePhotoNotifications(currentUser, fromUser *User) error {
}
// Who to clear the notifications for?
if fromUser == nil {
if fromUserID == nil {
log.Info("RevokePrivatePhotoNotifications(%s): forget about private photo uploads for EVERYBODY on photo IDs: %v", currentUser.Username, photoIDs)
return RemoveNotificationBulk("photos", photoIDs)
} else {
log.Info("RevokePrivatePhotoNotifications(%s): forget about private photo uploads for user %s on photo IDs: %v", currentUser.Username, fromUser.Username, photoIDs)
return RemoveSpecificNotificationBulk([]*User{currentUser, fromUser}, NotificationNewPhoto, "photos", photoIDs)
log.Info("RevokePrivatePhotoNotifications(%s): forget about private photo uploads for user %d on photo IDs: %v", currentUser.Username, *fromUserID, photoIDs)
return RemoveSpecificNotificationBulk(*fromUserID, NotificationNewPhoto, "photos", photoIDs)
}
}

View File

@ -184,11 +184,6 @@ func (t *Thread) DeleteReply(comment *Comment) error {
return errors.New("that comment doesn't belong to this thread")
}
// Revoke any notifications sent to subscribers when this reply was first created.
if err := RemoveAlsoPostedNotification(t, comment.ID); err != nil {
log.Error("Thread.DeleteReply: RemoveAlsoPostedNotification: %s", err)
}
// Is this the primary comment that started the thread? If so, delete the whole thread.
if comment.ID == t.CommentID {
log.Error("DeleteReply(%d): this is the parent comment of a thread (%d '%s'), remove the whole thread", comment.ID, t.ID, t.Title)

View File

@ -17,10 +17,10 @@ import (
// User account table.
type User struct {
ID uint64 `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex"`
Email string `gorm:"uniqueIndex"`
HashedPassword string `json:"-"`
ID uint64 `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex"`
Email string `gorm:"uniqueIndex"`
HashedPassword string
IsAdmin bool `gorm:"index"`
Status UserStatus `gorm:"index"` // active, disabled
Visibility UserVisibility `gorm:"index"` // public, private
@ -34,10 +34,10 @@ type User struct {
LastLoginAt time.Time `gorm:"index"`
// Relational tables.
ProfileField []ProfileField `json:"-"`
ProfileField []ProfileField
ProfilePhotoID *uint64
ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"`
AdminGroups []*AdminGroup `gorm:"many2many:admin_group_users;" json:"-"`
AdminGroups []*AdminGroup `gorm:"many2many:admin_group_users;"`
// Current user's relationship to this user -- not stored in DB.
UserRelationship UserRelationship `gorm:"-"`
@ -186,28 +186,6 @@ func FindUser(username string) (*User, error) {
return u, result.Error
}
// IsValidUsername checks if a username is available and not reserved.
func IsValidUsername(username string) error {
// Check the formatting of the name.
if !config.UsernameRegexp.MatchString(username) {
return errors.New("Your username must consist of only numbers, letters, - . and be 3-32 characters.")
}
// Reserved username check.
for _, cmp := range config.ReservedUsernames {
if username == cmp {
return errors.New("That username is reserved, please choose a different username.")
}
}
// Does the username already exist?
if _, err := FindUser(username); err == nil {
return errors.New("That username already exists. Please try a different username.")
}
return nil
}
// IsShyFrom tells whether the user is shy from the perspective of the other user.
//
// That is, depending on our profile visibility and friendship status.
@ -226,31 +204,6 @@ func (u *User) IsShyFrom(other *User) bool {
return true
}
// CanBeSeenBy checks whether the user can be seen to exist by the viewer.
//
// An admin viewer can always see them, but a user may be hidden to others when they are
// blocking, disabled or banned.
//
// The user should always be given a Not Found page so they can't tell the user even
// exists. The returned error will include a specific reason, for debugging purposes.
func (u *User) CanBeSeenBy(viewer *User) error {
if viewer.IsAdmin {
return nil
}
// Banned or disabled? Only admin can view then.
if u.Status != UserStatusActive {
return fmt.Errorf("user status is %s", u.Status)
}
// Is either one blocking?
if IsBlocking(viewer.ID, u.ID) && !viewer.IsAdmin {
return fmt.Errorf("users block each other")
}
return nil
}
// UserSearch config.
type UserSearch struct {
Username string
@ -263,8 +216,6 @@ type UserSearch struct {
InnerCircle bool
ShyAccounts bool
IsBanned bool
IsDisabled bool
IsAdmin bool // search for admin users
Friends bool
AgeMin int
AgeMax int
@ -294,7 +245,7 @@ func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, er
// If the current user doesn't have their location on file, they can't do this.
if myLocation.Source == LocationSourceNone || (myLocation.Latitude == 0 && myLocation.Longitude == 0) {
return users, errors.New("can not sort members by distance because your location is not known")
return users, errors.New("can not order by distance because your location is not known")
}
// Only query for users who have locations.
@ -364,22 +315,14 @@ func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, er
placeholders = append(placeholders, "here_for", "%"+search.HereFor+"%")
}
// Only admin user can show disabled/banned users.
var statuses = []string{}
if user.HasAdminScope(config.ScopeUserBan) {
if search.IsBanned {
statuses = append(statuses, UserStatusBanned)
}
if search.IsDisabled {
statuses = append(statuses, UserStatusDisabled)
}
}
// Non-admin user only ever sees active accounts.
if user.IsAdmin && len(statuses) > 0 {
// All user searches will show active accounts only, unless we are admin.
if user.IsAdmin && search.IsBanned {
wheres = append(wheres, "status IN ?")
placeholders = append(placeholders, statuses)
} else {
placeholders = append(placeholders, []string{
UserStatusBanned,
UserStatusDisabled,
})
} else if !user.IsAdmin {
wheres = append(wheres, "status = ?")
placeholders = append(placeholders, UserStatusActive)
}
@ -396,10 +339,6 @@ func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, er
placeholders = append(placeholders, false)
}
if search.IsAdmin {
wheres = append(wheres, "is_admin = true")
}
if search.InnerCircle {
wheres = append(wheres, "inner_circle = ? OR is_admin = ?")
placeholders = append(placeholders, true, true)

View File

@ -57,7 +57,7 @@ func RemoveFromInnerCircle(u *User) error {
if err := DB.Model(&Photo{}).Where(
"user_id = ? AND visibility = ?",
u.ID, PhotoInnerCircle,
).Update("visibility", PhotoPrivate); err != nil {
).Update("visibility", PhotoFriends); err != nil {
log.Error("RemoveFromInnerCircle: couldn't update photo visibility: %s", err.Error)
}

95
pkg/photo/face_score.go Normal file
View File

@ -0,0 +1,95 @@
package photo
import (
"errors"
"os"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
pigo "github.com/esimov/pigo/core"
)
// Functionality to do with face detection in pictures.
var (
faceScoreReady bool
faceClassifier *pigo.Pigo
cascadeFile []byte
)
// InitFaceScore initializes the face recognition library (esimov/pigo).
func InitFaceScore() {
if faceScoreReady {
return
}
if !config.Current.FaceScore.Enabled {
log.Error("InitFaceScore: not enabled in settings, face detection will not run")
return
}
// Load the cascade file needed for face detection.
data, err := os.ReadFile(config.Current.FaceScore.CascadeFile)
if err != nil {
log.Error("InitFaceScore: could not load cascade file (%s): %s", config.Current.FaceScore.CascadeFile, err)
return
}
cascadeFile = data
log.Info("Initializing FaceScore with cascade file (%d bytes)", len(cascadeFile))
faceClassifier = pigo.NewPigo()
faceClassifier, err = faceClassifier.Unpack(cascadeFile)
if err != nil {
log.Error("InitFaceScore: could not unpack the cascade file: %s", err)
return
}
faceScoreReady = true
}
// ComputeFaceScore checks a photo on disk and returns the detected face score.
func ComputeFaceScore(filename string) (float64, error) {
if !faceScoreReady {
return 0, errors.New("face detection is not available")
}
src, err := pigo.GetImage(filename)
if err != nil {
return 0, err
}
var (
pixels = pigo.RgbToGrayscale(src)
cols, rows = src.Bounds().Max.X, src.Bounds().Max.Y
cParams = pigo.CascadeParams{
MinSize: 20,
MaxSize: 1000,
ShiftFactor: 0.1,
ScaleFactor: 1.1,
ImageParams: pigo.ImageParams{
Pixels: pixels,
Rows: rows,
Cols: cols,
Dim: cols,
},
}
)
// Run the classifier.
dets := faceClassifier.RunCascade(cParams, 0.0)
for _, row := range dets {
log.Warn("%+v", row)
}
// Note: the classifier may return multiple matched faces, return the highest score.
var highest float32
for _, row := range dets {
if row.Q > highest {
highest = row.Q
}
}
return float64(highest), nil
}

View File

@ -14,13 +14,11 @@ import (
"code.nonshy.com/nonshy/website/pkg/controller/comment"
"code.nonshy.com/nonshy/website/pkg/controller/forum"
"code.nonshy.com/nonshy/website/pkg/controller/friend"
"code.nonshy.com/nonshy/website/pkg/controller/htmx"
"code.nonshy.com/nonshy/website/pkg/controller/inbox"
"code.nonshy.com/nonshy/website/pkg/controller/index"
"code.nonshy.com/nonshy/website/pkg/controller/photo"
"code.nonshy.com/nonshy/website/pkg/controller/poll"
"code.nonshy.com/nonshy/website/pkg/middleware"
nst "code.nonshy.com/nonshy/website/pkg/templates"
)
func New() http.Handler {
@ -28,21 +26,21 @@ func New() http.Handler {
// Register controller endpoints.
mux.HandleFunc("/", index.Create())
mux.HandleFunc("GET /favicon.ico", index.Favicon())
mux.HandleFunc("GET /manifest.json", index.Manifest())
mux.HandleFunc("GET /about", index.StaticTemplate("about.html")())
mux.HandleFunc("GET /features", index.StaticTemplate("features.html")())
mux.HandleFunc("GET /faq", index.StaticTemplate("faq.html")())
mux.HandleFunc("GET /tos", index.StaticTemplate("tos.html")())
mux.HandleFunc("GET /privacy", index.StaticTemplate("privacy.html")())
mux.HandleFunc("/favicon.ico", index.Favicon())
mux.HandleFunc("/manifest.json", index.Manifest())
mux.HandleFunc("/about", index.StaticTemplate("about.html")())
mux.HandleFunc("/features", index.StaticTemplate("features.html")())
mux.HandleFunc("/faq", index.StaticTemplate("faq.html")())
mux.HandleFunc("/tos", index.StaticTemplate("tos.html")())
mux.HandleFunc("/privacy", index.StaticTemplate("privacy.html")())
mux.HandleFunc("/contact", index.Contact())
mux.HandleFunc("/login", account.Login())
mux.HandleFunc("GET /logout", account.Logout())
mux.HandleFunc("/logout", account.Logout())
mux.Handle("/signup", middleware.GeoGate(account.Signup()))
mux.HandleFunc("/forgot-password", account.ForgotPassword())
mux.HandleFunc("GET /settings/confirm-email", account.ConfirmEmailChange())
mux.HandleFunc("GET /markdown", index.StaticTemplate("markdown.html")())
mux.HandleFunc("GET /test/geo-gate", index.StaticTemplate("errors/geo_gate.html")())
mux.HandleFunc("/settings/confirm-email", account.ConfirmEmailChange())
mux.HandleFunc("/markdown", index.StaticTemplate("markdown.html")())
mux.HandleFunc("/test/geo-gate", index.StaticTemplate("errors/geo_gate.html")())
// Login Required. Pages that non-certified users can access.
mux.Handle("/me", middleware.LoginRequired(account.Dashboard()))
@ -51,86 +49,75 @@ func New() http.Handler {
mux.Handle("/account/two-factor/setup", middleware.LoginRequired(account.Setup2FA()))
mux.Handle("/account/delete", middleware.LoginRequired(account.Delete()))
mux.Handle("/account/deactivate", middleware.LoginRequired(account.Deactivate()))
mux.Handle("GET /account/reactivate", middleware.LoginRequired(account.Reactivate()))
mux.Handle("GET /u/{username}", account.Profile()) // public access OK
mux.Handle("GET /u/{username}/friends", middleware.CertRequired(account.UserFriends()))
mux.Handle("GET /u/{username}/photos", middleware.LoginRequired(photo.UserPhotos()))
mux.Handle("/u/{username}/notes", middleware.LoginRequired(account.UserNotes()))
mux.Handle("/account/reactivate", middleware.LoginRequired(account.Reactivate()))
mux.Handle("/u/", account.Profile()) // public access OK
mux.Handle("/photo/upload", middleware.LoginRequired(photo.Upload()))
mux.Handle("GET /photo/view", middleware.LoginRequired(photo.View()))
mux.Handle("/photo/u/", middleware.LoginRequired(photo.UserPhotos()))
mux.Handle("/photo/view", middleware.LoginRequired(photo.View()))
mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit()))
mux.Handle("/photo/delete", middleware.LoginRequired(photo.Delete()))
mux.Handle("/photo/certification", middleware.LoginRequired(photo.Certification()))
mux.Handle("GET /photo/private", middleware.LoginRequired(photo.Private()))
mux.Handle("/photo/private", middleware.LoginRequired(photo.Private()))
mux.Handle("/photo/private/share", middleware.LoginRequired(photo.Share()))
mux.Handle("/notes/u/", middleware.LoginRequired(account.UserNotes()))
mux.Handle("/notes/me", middleware.LoginRequired(account.MyNotes()))
mux.Handle("GET /messages", middleware.LoginRequired(inbox.Inbox()))
mux.Handle("GET /messages/read/{id}", middleware.LoginRequired(inbox.Inbox()))
mux.Handle("/messages", middleware.LoginRequired(inbox.Inbox()))
mux.Handle("/messages/read/", middleware.LoginRequired(inbox.Inbox()))
mux.Handle("/messages/compose", middleware.LoginRequired(inbox.Compose()))
mux.Handle("/messages/delete", middleware.LoginRequired(inbox.Delete()))
mux.Handle("GET /friends", middleware.LoginRequired(friend.Friends()))
mux.Handle("/friends", middleware.LoginRequired(friend.Friends()))
mux.Handle("/friends/add", middleware.LoginRequired(friend.AddFriend()))
mux.Handle("POST /users/block", middleware.LoginRequired(block.BlockUser()))
mux.Handle("GET /users/blocked", middleware.LoginRequired(block.Blocked()))
mux.Handle("GET /users/blocklist/add", middleware.LoginRequired(block.AddUser()))
mux.Handle("/friends/u/", middleware.CertRequired(account.UserFriends()))
mux.Handle("/users/block", middleware.LoginRequired(block.BlockUser()))
mux.Handle("/users/blocked", middleware.LoginRequired(block.Blocked()))
mux.Handle("/users/blocklist/add", middleware.LoginRequired(block.AddUser()))
mux.Handle("/comments", middleware.LoginRequired(comment.PostComment()))
mux.Handle("GET /comments/subscription", middleware.LoginRequired(comment.Subscription()))
mux.Handle("GET /admin/unimpersonate", middleware.LoginRequired(admin.Unimpersonate()))
mux.Handle("GET /inner-circle", middleware.LoginRequired(account.InnerCircle()))
mux.Handle("/comments/subscription", middleware.LoginRequired(comment.Subscription()))
mux.Handle("/admin/unimpersonate", middleware.LoginRequired(admin.Unimpersonate()))
mux.Handle("/inner-circle", middleware.LoginRequired(account.InnerCircle()))
mux.Handle("/inner-circle/invite", middleware.LoginRequired(account.InviteCircle()))
mux.Handle("GET /admin/transparency/{username}", middleware.LoginRequired(admin.Transparency()))
// Certification Required. Pages that only full (verified) members can access.
mux.Handle("GET /photo/gallery", middleware.CertRequired(photo.SiteGallery()))
mux.Handle("GET /members", middleware.CertRequired(account.Search()))
mux.Handle("/photo/gallery", middleware.CertRequired(photo.SiteGallery()))
mux.Handle("/members", middleware.CertRequired(account.Search()))
mux.Handle("/chat", middleware.CertRequired(chat.Landing()))
mux.Handle("GET /forum", middleware.CertRequired(forum.Landing()))
mux.Handle("/forum", middleware.CertRequired(forum.Landing()))
mux.Handle("/forum/post", middleware.CertRequired(forum.NewPost()))
mux.Handle("GET /forum/thread/{id}", middleware.CertRequired(forum.Thread()))
mux.Handle("GET /forum/newest", middleware.CertRequired(forum.Newest()))
mux.Handle("GET /forum/search", middleware.CertRequired(forum.Search()))
mux.Handle("GET /f/{fragment}", middleware.CertRequired(forum.Forum()))
mux.Handle("POST /poll/vote", middleware.CertRequired(poll.Vote()))
mux.Handle("/forum/thread/", middleware.CertRequired(forum.Thread()))
mux.Handle("/forum/newest", middleware.CertRequired(forum.Newest()))
mux.Handle("/forum/search", middleware.CertRequired(forum.Search()))
mux.Handle("/f/", middleware.CertRequired(forum.Forum()))
mux.Handle("/poll/vote", middleware.CertRequired(poll.Vote()))
// Admin endpoints.
mux.Handle("GET /admin", middleware.AdminRequired("", admin.Dashboard()))
mux.Handle("/admin", middleware.AdminRequired("", admin.Dashboard()))
mux.Handle("/admin/scopes", middleware.AdminRequired("", admin.Scopes()))
mux.Handle("/admin/photo/certification", middleware.AdminRequired("", photo.AdminCertification()))
mux.Handle("/admin/feedback", middleware.AdminRequired(config.ScopeFeedbackAndReports, admin.Feedback()))
mux.Handle("/admin/feedback", middleware.AdminRequired("", admin.Feedback()))
mux.Handle("/admin/user-action", middleware.AdminRequired("", admin.UserActions()))
mux.Handle("/admin/maintenance", middleware.AdminRequired(config.ScopeMaintenance, admin.Maintenance()))
mux.Handle("/forum/admin", middleware.AdminRequired(config.ScopeForumAdmin, forum.Manage()))
mux.Handle("/forum/admin/edit", middleware.AdminRequired(config.ScopeForumAdmin, forum.AddEdit()))
mux.Handle("/inner-circle/remove", middleware.LoginRequired(account.RemoveCircle()))
mux.Handle("/admin/photo/mark-explicit", middleware.AdminRequired(config.ScopePhotoModerator, admin.MarkPhotoExplicit()))
mux.Handle("GET /admin/changelog", middleware.AdminRequired(config.ScopeChangeLog, admin.ChangeLog()))
// JSON API endpoints.
mux.HandleFunc("GET /v1/version", api.Version())
mux.HandleFunc("GET /v1/users/me", api.LoginOK())
mux.HandleFunc("POST /v1/users/check-username", api.UsernameCheck())
mux.Handle("POST /v1/likes", middleware.LoginRequired(api.Likes()))
mux.Handle("GET /v1/likes/users", middleware.LoginRequired(api.WhoLikes()))
mux.Handle("POST /v1/notifications/read", middleware.LoginRequired(api.ReadNotification()))
mux.Handle("POST /v1/notifications/delete", middleware.LoginRequired(api.ClearNotification()))
mux.Handle("POST /v1/photos/mark-explicit", middleware.LoginRequired(api.MarkPhotoExplicit()))
mux.Handle("GET /v1/comment-photos/remove-orphaned", api.RemoveOrphanedCommentPhotos())
mux.Handle("POST /v1/barertc/report", barertc.Report())
mux.Handle("POST /v1/barertc/profile", barertc.Profile())
// HTMX endpoints.
mux.Handle("GET /htmx/user/profile/activity", middleware.LoginRequired(htmx.UserProfileActivityCard()))
mux.HandleFunc("/v1/version", api.Version())
mux.HandleFunc("/v1/users/me", api.LoginOK())
mux.HandleFunc("/v1/users/check-username", api.UsernameCheck())
mux.Handle("/v1/likes", middleware.LoginRequired(api.Likes()))
mux.Handle("/v1/likes/users", middleware.LoginRequired(api.WhoLikes()))
mux.Handle("/v1/notifications/read", middleware.LoginRequired(api.ReadNotification()))
mux.Handle("/v1/notifications/delete", middleware.LoginRequired(api.ClearNotification()))
mux.Handle("/v1/comment-photos/remove-orphaned", api.RemoveOrphanedCommentPhotos())
mux.Handle("/v1/barertc/report", barertc.Report())
mux.Handle("/v1/barertc/profile", barertc.Profile())
// Redirect endpoints.
mux.Handle("GET /go/comment", middleware.LoginRequired(comment.GoToComment()))
mux.Handle("/go/comment", middleware.LoginRequired(comment.GoToComment()))
// Static files.
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(config.StaticPath))))
// Legacy route redirects (Go 1.22 path parameters update)
mux.Handle("GET /friends/u/{s}", nst.RedirectRoute("/u/%s/friends"))
mux.Handle("GET /photo/u/{s}", nst.RedirectRoute("/u/%s/photos"))
mux.Handle("GET /notes/u/{s}", nst.RedirectRoute("/u/%s/notes"))
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(config.StaticPath))))
// Global middlewares.
withCSRF := middleware.CSRF(mux)

View File

@ -32,7 +32,6 @@ const (
ContextKey = "session"
CurrentUserKey = "current_user"
CSRFKey = "csrf"
RequestTimeKey = "req_time"
)
// New creates a blank session object.

View File

@ -1,32 +0,0 @@
package spam
import (
"errors"
"strings"
)
// SpamWebsites to third-party video hosting apps: we already have our own chat room, and third-party links shared in
// public places can pose a risk to user privacy/safety.
var SpamWebsites = []string{
"join.skype.com",
"zoom.us",
"whereby.com",
"meet.jit.si",
"https://t.me",
}
// DetectSpamMessage searches a message (such as a comment, forum post, etc.) for spammy contents such as Skype invite links
// and returns an error if found.
func DetectSpamMessage(message string) error {
for _, link := range SpamWebsites {
if strings.Contains(message, link) {
return errors.New(
"Your message could not be posted because it contains a link to a third-party video chat website. " +
"In the interest of protecting our community, we do not allow linking to third-party video conferencing apps where user " +
"privacy and security may not hold up to our standards, or where the content may run against our terms of service.",
)
}
}
return nil
}

View File

@ -1,37 +1,9 @@
package templates
import (
"fmt"
"net/http"
)
import "net/http"
// Redirect sends an HTTP header to the browser.
func Redirect(w http.ResponseWriter, url string) {
w.Header().Set("Location", url)
w.WriteHeader(http.StatusFound)
}
/*
RedirectRoute redirects an old URL route to a newer version.
This was added for the Go 1.22 path parameter update to the standard lib
router. Before this update, routes with path parameters were handled by
regexp parsing inside the controller functions and I didn't want to overload
too many endpoints sharing a common prefix but with 1.22 path parameters
this is easier to do.
Examples:
* /u/{username}/friends instead of /friends/u/{username}
* /u/{username}/notes instead of /notes/u/{username}
*/
func RedirectRoute(path string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var a = r.PathValue("s")
if a != "" {
Redirect(w, fmt.Sprintf(path, a))
return
}
Redirect(w, path)
}
}

View File

@ -40,7 +40,6 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
"ToHTML": ToHTML,
"PhotoURL": photo.URLPath,
"Now": time.Now,
"RunTime": RunTime,
"PrettyTitle": func() template.HTML {
return template.HTML(fmt.Sprintf(
`<strong style="color: #0077FF">non</strong>` +
@ -71,9 +70,6 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
// Test if a photo should be blurred ({{BlurExplicit .Photo}})
"BlurExplicit": BlurExplicit(r),
// Get a description for an admin scope (e.g. for transparency page).
"AdminScopeDescription": config.AdminScopeDescription,
}
}
@ -93,15 +89,6 @@ func InputCSRF(r *http.Request) func() template.HTML {
}
}
// RunTime returns the elapsed time between the HTTP request start and now, as a formatted string.
func RunTime(r *http.Request) string {
if rt, ok := r.Context().Value(session.RequestTimeKey).(time.Time); ok {
duration := time.Since(rt)
return duration.Round(time.Millisecond).String()
}
return "ERROR"
}
// BlurExplicit returns true if the current user has the blur_explicit setting on and the given Photo is Explicit.
func BlurExplicit(r *http.Request) func(*models.Photo) bool {
return func(photo *models.Photo) bool {

View File

@ -18,7 +18,6 @@ func MergeVars(r *http.Request, m map[string]interface{}) {
m["BuildDate"] = config.RuntimeBuildDate
m["Subtitle"] = config.Subtitle
m["YYYY"] = time.Now().Year()
m["WebsiteTheme"] = ""
if r == nil {
return
@ -56,9 +55,6 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
m["LoggedIn"] = true
m["CurrentUser"] = user
// User website preferences
m["WebsiteTheme"] = user.GetProfileField("website-theme")
// Get user recent notifications.
/*notifPager := &models.Pagination{
Page: 1,
@ -104,20 +100,11 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
log.Error("MergeUserVars: couldn't CountFriendRequests for %d: %s", user.ID, err)
}
// Are we admin? Add notification counts if the current admin can respond to them.
// Are we admin?
if user.IsAdmin {
var countCertPhotos, countFeedback int64
// Any pending certification photos or feedback?
if user.HasAdminScope(config.ScopeCertificationApprove) {
countCertPhotos = models.CountCertificationPhotosNeedingApproval()
}
// Admin feedback available?
if user.HasAdminScope(config.ScopeFeedbackAndReports) {
countFeedback = models.CountUnreadFeedback()
}
countCertPhotos = models.CountCertificationPhotosNeedingApproval()
countFeedback = models.CountUnreadFeedback()
m["NavCertificationPhotos"] = countCertPhotos
m["NavAdminFeedback"] = countFeedback

View File

@ -46,28 +46,6 @@ func LoadTemplate(filename string) (*Template, error) {
}, nil
}
// LoadCustom loads a bare template without the site theme and partial templates attached.
//
// The custom TempleFuncs and vars are still available (PrettyTitle, .CurrentUser, etc.)
func LoadCustom(filename string) (*Template, error) {
filepath := config.TemplatePath + "/" + filename
stat, err := os.Stat(filepath)
if err != nil {
return nil, fmt.Errorf("LoadTemplate(%s): %s", filename, err)
}
tmpl := template.New("page")
tmpl.Funcs(TemplateFuncs(nil))
tmpl.ParseFiles(filepath)
return &Template{
filename: filename,
filepath: filepath,
modified: stat.ModTime(),
tmpl: tmpl,
}, nil
}
// Must LoadTemplate or panic.
func Must(filename string) *Template {
tmpl, err := LoadTemplate(filename)
@ -77,15 +55,6 @@ func Must(filename string) *Template {
return tmpl
}
// Must LoadCustom or panic.
func MustLoadCustom(filename string) *Template {
tmpl, err := LoadCustom(filename)
if err != nil {
panic(err)
}
return tmpl
}
// Execute a loaded template. In debug mode, the template file may be reloaded
// from disk if the file on disk has been modified.
func (t *Template) Execute(w http.ResponseWriter, r *http.Request, vars map[string]interface{}) error {
@ -165,7 +134,6 @@ var baseTemplates = []string{
config.TemplatePath + "/partials/user_avatar.html",
config.TemplatePath + "/partials/like_modal.html",
config.TemplatePath + "/partials/right_click.html",
config.TemplatePath + "/partials/mark_explicit.html",
config.TemplatePath + "/partials/themes.html",
}

View File

@ -2,7 +2,7 @@ package worker
import (
"encoding/json"
"io"
"io/ioutil"
"net/http"
"sync"
"time"
@ -22,27 +22,27 @@ type ChatStatistics struct {
}
// GetChatStatistics returns the latest (cached) chat statistics.
func GetChatStatistics() *ChatStatistics {
func GetChatStatistics() ChatStatistics {
chatStatisticsMu.RLock()
defer chatStatisticsMu.RUnlock()
return cachedChatStatistics
if cachedChatStatistics != nil {
return *cachedChatStatistics
}
return ChatStatistics{
Usernames: []string{},
}
}
// SetChatStatistics updates the cached chat statistics, holding a write lock briefly.
func SetChatStatistics(stats *ChatStatistics) {
chatStatisticsMu.Lock()
defer chatStatisticsMu.Unlock()
if stats == nil {
cachedChatStatistics = &ChatStatistics{}
return
}
cachedChatStatistics = stats
}
// IsOnline returns whether the username is currently logged-in to chat.
func (cs *ChatStatistics) IsOnline(username string) bool {
func (cs ChatStatistics) IsOnline(username string) bool {
for _, user := range cs.Usernames {
if user == username {
return true
@ -51,20 +51,10 @@ func (cs *ChatStatistics) IsOnline(username string) bool {
return false
}
// SetOnlineNow patches the current ChatStatistics to mark a user as online immediately, e.g.
// because the main site has just sent them to the chat with a JWT token.
func (cs *ChatStatistics) SetOnlineNow(username string) {
if !cs.IsOnline(username) {
chatStatisticsMu.Lock()
defer chatStatisticsMu.Unlock()
cs.Usernames = append(cs.Usernames, username)
}
}
type UserOnChatMap map[string]bool
// MapUsersOnline returns a hashmap of usernames to online status.
func (cs *ChatStatistics) MapUsersOnline(usernames []string) UserOnChatMap {
func (cs ChatStatistics) MapUsersOnline(usernames []string) UserOnChatMap {
var result = UserOnChatMap{}
for _, user := range cs.Usernames {
result[user] = true
@ -78,7 +68,7 @@ func (m UserOnChatMap) Get(username string) bool {
}
var (
cachedChatStatistics = &ChatStatistics{}
cachedChatStatistics *ChatStatistics
chatStatisticsMu sync.RWMutex
)
@ -127,7 +117,7 @@ func DoCheckBareRTC() {
if res.StatusCode == http.StatusOK {
var cs ChatStatistics
body, _ := io.ReadAll(res.Body)
body, _ := ioutil.ReadAll(res.Body)
res.Body.Close()
if err = json.Unmarshal(body, &cs); err != nil {
log.Error("WatchBareRTC: json decode error: %s", err)

View File

@ -1,54 +0,0 @@
/* Forced dark theme for Bulma (custom created for nonshy) */
/* nonshy custom overrides */
@import url("dark-theme.css");
/* Copied from bulma.css - original dark theme styles */
:root {
--bulma-white-on-scheme-l: 100%;
--bulma-white-on-scheme: hsla(var(--bulma-white-h), var(--bulma-white-s), var(--bulma-white-on-scheme-l), 1);
--bulma-black-on-scheme-l: 0%;
--bulma-black-on-scheme: hsla(var(--bulma-black-h), var(--bulma-black-s), var(--bulma-black-on-scheme-l), 1);
--bulma-light-on-scheme-l: 96%;
--bulma-light-on-scheme: hsla(var(--bulma-light-h), var(--bulma-light-s), var(--bulma-light-on-scheme-l), 1);
--bulma-dark-on-scheme-l: 56%;
--bulma-dark-on-scheme: hsla(var(--bulma-dark-h), var(--bulma-dark-s), var(--bulma-dark-on-scheme-l), 1);
--bulma-text-on-scheme-l: 54%;
--bulma-text-on-scheme: hsla(var(--bulma-text-h), var(--bulma-text-s), var(--bulma-text-on-scheme-l), 1);
--bulma-primary-on-scheme-l: 41%;
--bulma-primary-on-scheme: hsla(var(--bulma-primary-h), var(--bulma-primary-s), var(--bulma-primary-on-scheme-l), 1);
--bulma-link-on-scheme-l: 73%;
--bulma-link-on-scheme: hsla(var(--bulma-link-h), var(--bulma-link-s), var(--bulma-link-on-scheme-l), 1);
--bulma-info-on-scheme-l: 70%;
--bulma-info-on-scheme: hsla(var(--bulma-info-h), var(--bulma-info-s), var(--bulma-info-on-scheme-l), 1);
--bulma-success-on-scheme-l: 53%;
--bulma-success-on-scheme: hsla(var(--bulma-success-h), var(--bulma-success-s), var(--bulma-success-on-scheme-l), 1);
--bulma-warning-on-scheme-l: 53%;
--bulma-warning-on-scheme: hsla(var(--bulma-warning-h), var(--bulma-warning-s), var(--bulma-warning-on-scheme-l), 1);
--bulma-danger-on-scheme-l: 70%;
--bulma-danger-on-scheme: hsla(var(--bulma-danger-h), var(--bulma-danger-s), var(--bulma-danger-on-scheme-l), 1);
--bulma-scheme-brightness: dark;
--bulma-scheme-main-l: 9%;
--bulma-scheme-main-bis-l: 11%;
--bulma-scheme-main-ter-l: 13%;
--bulma-soft-l: 20%;
--bulma-bold-l: 90%;
--bulma-soft-invert-l: 90%;
--bulma-bold-invert-l: 20%;
--bulma-background-l: 14%;
--bulma-border-weak-l: 21%;
--bulma-border-l: 24%;
--bulma-text-weak-l: 53%;
--bulma-text-l: 71%;
--bulma-text-strong-l: 93%;
--bulma-text-title-l: 100%;
--bulma-hover-background-l-delta: 5%;
--bulma-active-background-l-delta: 10%;
--bulma-hover-border-l-delta: 10%;
--bulma-active-border-l-delta: 20%;
--bulma-hover-color-l-delta: 5%;
--bulma-active-color-l-delta: 10%;
--bulma-shadow-h: 0deg;
--bulma-shadow-s: 0%;
--bulma-shadow-l: 100%;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

22437
web/static/css/bulma.css vendored

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,67 +0,0 @@
/* Custom nonshy color overrides for Bulma's dark theme */
/* nonshy custom overrides */
.has-background-primary-light {
background-color: rgba(28, 166, 76, 0.25) !important;
}
.has-background-info-light, .has-background-info {
background-color: rgb(26, 79, 95) !important
}
.has-background-success-light, .has-background-success {
background-color: rgba(19, 71, 37, 0.685) !important
}
.has-background-warning-light, .has-background-warning {
background-color: rgb(44, 40, 18) !important;
}
.has-background-danger-light, .has-background-danger {
background-color: rgb(31, 13, 13) !important;
}
.has-background-link-light {
background-color: rgba(15, 129, 204, 0.25) !important;
}
.nonshy-navbar-notification-tag.is-warning {
background-color: rgb(248, 223, 98) !important;
color: rgb(26, 0, 5) !important;
}
/* force lit-up notification buttons (on the mobile top nav, e.g. new Messages/Friends)
to show as a bright bulma is-warning style (.tag.is-warning) */
.nonshy-navbar-notification {
background-color: rgb(248, 223, 98) !important;
color: rgb(26, 0, 5) !important;
}
.has-text-dark {
/* note: this css file otherwise didn't override this, dark's always dark, brighten it! */
color: #b5b5b5 !important;
}
a.has-text-dark:focus,
a.has-text-dark:hover {
color: #d5d5d5 !important;
}
.modal-background {
background-color: rgba(0, 0, 0, 0.86) !important;
}
/* Tag color overrides */
.tag.is-grey {
background-color: #3f3f3f;
color: #eee;
}
.tag.is-danger.is-light {
background-color: #500;
color: #FCC;
}
.tag.is-warning {
background-color: #550;
color: #FFC;
}

View File

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

View File

@ -12,10 +12,6 @@ abbr {
cursor: pointer;
}
.cursor-default {
cursor: default;
}
img {
/* https://stackoverflow.com/questions/12906789/preventing-an-image-from-being-draggable-or-selectable-without-using-js */
user-drag: none;
@ -50,26 +46,9 @@ img {
/* Photo modals in addition to Bulma .modal-content */
.photo-modal {
max-width: calc(100vw - 40px);
max-height: calc(100vh - 40px);
width: auto;
}
.photo-modal #detailImg {
position: relative;
background-size: contain;
background-repeat: no-repeat;
background-position: center center;
}
.photo-modal img {
max-height: calc(100vh - 50px);
}
.photo-modal .alt-text {
position: absolute;
bottom: 4px;
left: 4px;
}
.line-breakable {
white-space: pre-line;
width: auto !important;
max-width: fit-content;
max-height: fit-content;
}
/* Custom bulma tag colors */
@ -117,11 +96,6 @@ img {
width: 100%;
}
/* Bulma supplement: full height cards e.g. for grid layout on home page */
.is-fullheight {
height: 100%;
}
/* Collapsible cards for mobile (e.g. filter cards) */
.card.nonshy-collapsible-mobile {
cursor: pointer;
@ -196,12 +170,4 @@ img {
.tag.is-mixed {
background: linear-gradient(141deg, #ff0537 0, #3ec487 100%);
color: #fff;
}
/* Home page marketing styles */
.nonshy-home-card .card-header {
background-position: right 12px center;
background-repeat: no-repeat;
background-color: #400040;
height: 64px;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

View File

@ -97,15 +97,14 @@ document.addEventListener('DOMContentLoaded', () => {
(document.querySelectorAll(".card.nonshy-collapsible-mobile") || []).forEach(node => {
const header = node.querySelector(".card-header"),
body = node.querySelector(".card-content"),
icon = header.querySelector("button.card-header-icon > .icon > i"),
always = node.classList.contains("nonshy-collapsible-always");
icon = header.querySelector("button.card-header-icon > .icon > i");
// Icon classes.
const iconExpanded = "fa-angle-up",
iconContracted = "fa-angle-down";
// If we are already on mobile, hide the body now.
if (screen.width <= 768 || always) {
if (screen.width <= 768) {
body.style.display = "none";
if (icon !== null) {
icon.classList.remove(iconExpanded);

File diff suppressed because one or more lines are too long

View File

@ -1,42 +0,0 @@
// nonshy inline "Quote" and "Reply" buttons that activate the comment field
// on the current page. Common logic between forum threads and photo comments.
document.addEventListener('DOMContentLoaded', function() {
const $message = document.querySelector("#message");
// Enhance the in-post Quote and Reply buttons to activate the reply field
// at the page header instead of going to the dedicated comment page.
(document.querySelectorAll(".nonshy-quote-button") || []).forEach(node => {
const message = node.dataset.quoteBody,
replyTo = node.dataset.replyTo;
node.addEventListener("click", (e) => {
e.preventDefault();
if (replyTo) {
$message.value += "@" + replyTo + "\n\n";
}
// Prepare the quoted message.
var lines = [];
for (let line of message.split("\n")) {
lines.push("> " + line);
}
$message.value += lines.join("\n") + "\n\n";
$message.scrollIntoView();
$message.focus();
});
});
(document.querySelectorAll(".nonshy-reply-button") || []).forEach(node => {
const replyTo = node.dataset.replyTo;
node.addEventListener("click", (e) => {
e.preventDefault();
$message.value += "@" + replyTo + "\n\n";
$message.scrollIntoView();
$message.focus();
});
});
});

View File

@ -5,7 +5,7 @@ document.addEventListener('DOMContentLoaded', () => {
cls = 'is-active';
// Disable context menu on all images.
(document.querySelectorAll('img, video, #detailImg') || []).forEach(node => {
(document.querySelectorAll('img, video') || []).forEach(node => {
node.addEventListener('contextmenu', (e) => {
$modal.classList.add(cls);
e.preventDefault();

View File

@ -1,6 +1,6 @@
{
"short_name": "nonshy",
"name": "nonshy",
"name": "A social network for nudists and exhibitionists.",
"icons": [
{
"src": "/static/img/favicon.svg",

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