Compare commits
75 Commits
face-detec
...
main
Author | SHA1 | Date |
---|---|---|
Noah Petherbridge | 12a1adc270 | |
Noah Petherbridge | a284aab026 | |
Noah Petherbridge | b477ad5e73 | |
Noah Petherbridge | c566e444c7 | |
Noah Petherbridge | ed008a99e6 | |
Noah Petherbridge | 7c7d3a11e5 | |
Noah Petherbridge | 9db7343370 | |
Noah Petherbridge | 0f6dd58c54 | |
Noah Petherbridge | 20d04fc370 | |
Noah Petherbridge | 31ba987d62 | |
Noah Petherbridge | fdf0aee5da | |
Noah Petherbridge | 198849eebc | |
Noah Petherbridge | a00aec7488 | |
Noah Petherbridge | 2f352f8664 | |
Noah Petherbridge | 04f1c56809 | |
Noah Petherbridge | 106ca56198 | |
Noah Petherbridge | ff2eb285eb | |
Noah Petherbridge | 382c6df96c | |
Noah Petherbridge | 19d06c183f | |
Noah Petherbridge | f4721d65da | |
Noah Petherbridge | e7f7f4d0d3 | |
Noah Petherbridge | a0f41074bd | |
Noah Petherbridge | 4623cdca50 | |
Noah Petherbridge | e947a005d9 | |
Noah Petherbridge | 32b054cacf | |
Noah Petherbridge | 6866bec972 | |
Noah Petherbridge | 7dc1ebd63f | |
Noah Petherbridge | 360ad41543 | |
Noah Petherbridge | 2126c5ab84 | |
Noah Petherbridge | 2f75059623 | |
Noah Petherbridge | 763b9e4404 | |
Noah Petherbridge | 268a177412 | |
Noah Petherbridge | ddd33aad91 | |
Noah Petherbridge | 4ff7bc5d04 | |
Noah Petherbridge | a47202d756 | |
Noah Petherbridge | ff69b8f771 | |
Noah Petherbridge | c8238c1749 | |
Noah Petherbridge | 58eaf53694 | |
Noah Petherbridge | 6a483929d2 | |
Noah Petherbridge | fe2e43245b | |
Noah Petherbridge | a669b58c55 | |
Noah Petherbridge | ad59440b2b | |
Noah Petherbridge | 2d0fd25a08 | |
Noah Petherbridge | 535e96b491 | |
Noah Petherbridge | 2ab34a39a3 | |
Noah Petherbridge | d4e3aa755b | |
Noah Petherbridge | 4f3f6de158 | |
Noah Petherbridge | 35258beb36 | |
Noah Petherbridge | 1c2982aec0 | |
Noah Petherbridge | d623f0bc3c | |
Noah Petherbridge | 04a7616299 | |
Noah Petherbridge | 9c4ec85f8a | |
Noah Petherbridge | cf6249c415 | |
Noah Petherbridge | 742a5fa1af | |
Noah Petherbridge | be9276f4c0 | |
Noah Petherbridge | 80c4471017 | |
Noah Petherbridge | 28111585ef | |
Noah Petherbridge | dd24aa1987 | |
Noah Petherbridge | 2820cf581e | |
Noah Petherbridge | 3142e0ce84 | |
Noah Petherbridge | f4d176a538 | |
noah | 85d2f4eee9 | |
Noah Petherbridge | 62d56d5924 | |
Noah Petherbridge | 3c0473c633 | |
Noah Petherbridge | 7ceb14053b | |
Noah Petherbridge | 7da650ffc4 | |
Noah Petherbridge | 588de52252 | |
Noah Petherbridge | 211c649f9a | |
Noah Petherbridge | fedfbed4eb | |
Noah Petherbridge | ef8abec7bf | |
Noah Petherbridge | a9cc758624 | |
Noah Petherbridge | 20d9bf7768 | |
Noah Petherbridge | f27b41a214 | |
Noah Petherbridge | b4cd57c8c3 | |
Noah Petherbridge | eed971d997 |
80
go.mod
80
go.mod
|
@ -1,63 +1,63 @@
|
|||
module code.nonshy.com/nonshy/website
|
||||
|
||||
go 1.18
|
||||
go 1.22
|
||||
|
||||
toolchain go1.22.0
|
||||
|
||||
require (
|
||||
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b
|
||||
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/urfave/cli/v2 v2.11.1
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
|
||||
gorm.io/driver/postgres v1.3.8
|
||||
gorm.io/driver/sqlite v1.3.6
|
||||
gorm.io/gorm v1.23.8
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.26
|
||||
github.com/oschwald/geoip2-golang v1.9.0
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629
|
||||
github.com/urfave/cli/v2 v2.27.1
|
||||
golang.org/x/crypto v0.19.0
|
||||
golang.org/x/image v0.15.0
|
||||
golang.org/x/text v0.14.0
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
gorm.io/driver/postgres v1.5.6
|
||||
gorm.io/driver/sqlite v1.5.5
|
||||
gorm.io/gorm v1.25.7
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||
github.com/boombuler/barcode v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f // indirect
|
||||
github.com/go-redis/redis v6.15.9+incompatible // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||
github.com/jackc/pgconn v1.12.1 // indirect
|
||||
github.com/jackc/pgio v1.0.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgproto3/v2 v2.3.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
|
||||
github.com/jackc/pgtype v1.11.0 // indirect
|
||||
github.com/jackc/pgx/v4 v4.16.1 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
|
||||
github.com/jackc/pgx/v5 v5.5.3 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.14 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.19 // indirect
|
||||
github.com/oschwald/geoip2-golang v1.9.0 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.11.0 // indirect
|
||||
github.com/pquerna/otp v1.4.0 // indirect
|
||||
github.com/russross/blackfriday v1.5.2 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.12.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||
github.com/russross/blackfriday v1.6.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||
github.com/sergi/go-diff v1.2.0 // indirect
|
||||
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 // indirect
|
||||
github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480 // indirect
|
||||
github.com/shurcooL/highlight_go v0.0.0-20191220051317-782971ddf21b // indirect
|
||||
github.com/shurcooL/octicon v0.0.0-20191102190552-cbb32d6a785c // indirect
|
||||
github.com/sergi/go-diff v1.3.1 // indirect
|
||||
github.com/shurcooL/go v0.0.0-20230706063926-5fe729b41b3a // indirect
|
||||
github.com/shurcooL/go-goon v1.0.0 // indirect
|
||||
github.com/shurcooL/highlight_diff v0.0.0-20230708024848-22f825814995 // indirect
|
||||
github.com/shurcooL/highlight_go v0.0.0-20230708025100-33e05792540a // indirect
|
||||
github.com/shurcooL/octicon v0.0.0-20230705024016-66bff059edb8 // indirect
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d // indirect
|
||||
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect
|
||||
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 // indirect
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect
|
||||
golang.org/x/sys v0.9.0 // indirect
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/tools v0.1.12 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect
|
||||
golang.org/x/net v0.21.0 // indirect
|
||||
golang.org/x/sync v0.6.0 // indirect
|
||||
golang.org/x/sys v0.17.0 // indirect
|
||||
golang.org/x/term v0.17.0 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect
|
||||
)
|
||||
|
|
301
go.sum
301
go.sum
|
@ -1,20 +1,16 @@
|
|||
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b h1:TDxEEWOJqMzsu9JW8/QgmT1lgQ9WD2KWlb2lKN/Ql2o=
|
||||
git.kirsle.net/go/log v0.0.0-20200902035305-70ac2848949b/go.mod h1:jl+Qr58W3Op7OCxIYIT+b42jq8xFncJXzPufhrvza7Y=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
|
||||
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
|
@ -22,253 +18,126 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1
|
|||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f h1:RMnUwTnNR070mFAEIoqMYjNirHj8i0h79VXTYyBCyVA=
|
||||
github.com/edwvee/exiffix v0.0.0-20210922235313-0f6cbda5e58f/go.mod h1:KoE3Ti1qbQXCb3s/XGj0yApHnbnNnn1bXTtB5Auq/Vc=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
|
||||
github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
|
||||
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
|
||||
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
|
||||
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
|
||||
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
|
||||
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
|
||||
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
|
||||
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
|
||||
github.com/jackc/pgconn v1.12.1 h1:rsDFzIpRk7xT4B8FufgpCCeyjdNpKyghZeSefViE5W8=
|
||||
github.com/jackc/pgconn v1.12.1/go.mod h1:ZkhRC59Llhrq3oSfrikvwQ5NaxYExr6twkdkMLaKono=
|
||||
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
||||
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
||||
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
|
||||
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
|
||||
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
|
||||
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.3.0 h1:brH0pCGBDkBW07HWlN/oSBXrmo3WB0UvZd1pIuDcL8Y=
|
||||
github.com/jackc/pgproto3/v2 v2.3.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
||||
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
|
||||
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
|
||||
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
|
||||
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
|
||||
github.com/jackc/pgtype v1.11.0 h1:u4uiGPz/1hryuXzyaBhSk6dnIyyG2683olG2OV+UUgs=
|
||||
github.com/jackc/pgtype v1.11.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
|
||||
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
|
||||
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
|
||||
github.com/jackc/pgx/v4 v4.16.1 h1:JzTglcal01DrghUqt+PmzWsZx/Yh7SC/CTQmSBMTd0Y=
|
||||
github.com/jackc/pgx/v4 v4.16.1/go.mod h1:SIhx0D5hoADaiXZVyv+3gSm3LCIIINTVO0PficsvWGQ=
|
||||
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.2.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
|
||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.5.3 h1:Ces6/M3wbDXYpM8JyyPD57ivTtJACFZJd885pdIaV2s=
|
||||
github.com/jackc/pgx/v5 v5.5.3/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw=
|
||||
github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/microcosm-cc/bluemonday v1.0.19 h1:OI7hoF5FY4pFz2VA//RN8TfM0YJ2dJcl4P4APrCWy6c=
|
||||
github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
|
||||
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
|
||||
github.com/oschwald/geoip2-golang v1.9.0 h1:uvD3O6fXAXs+usU+UGExshpdP13GAqp4GBrzN7IgKZc=
|
||||
github.com/oschwald/geoip2-golang v1.9.0/go.mod h1:BHK6TvDyATVQhKNbQBdrj9eAvuwOMi2zSFXizL3K81Y=
|
||||
github.com/oschwald/maxminddb-golang v1.11.0 h1:aSXMqYR/EPNjGE8epgqwDay+P30hCBZIveY0WZbAWh0=
|
||||
github.com/oschwald/maxminddb-golang v1.11.0/go.mod h1:YmVI+H0zh3ySFR3w+oz8PCfglAFj3PuCmui13+P9zDg=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs=
|
||||
github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww=
|
||||
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
|
||||
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629 h1:86e54L0i3pH3dAIA8OxBbfLrVyhoGpnNk1iJCigAWYs=
|
||||
github.com/shurcooL/github_flavored_markdown v0.0.0-20210228213109-c3a9aa474629/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0=
|
||||
github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480 h1:KaKXZldeYH73dpQL+Nr38j1r5BgpAYQjYvENOUpIZDQ=
|
||||
github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU=
|
||||
github.com/shurcooL/highlight_go v0.0.0-20191220051317-782971ddf21b h1:rBIwpb5ggtqf0uZZY5BPs1sL7njUMM7I8qD2jiou70E=
|
||||
github.com/shurcooL/highlight_go v0.0.0-20191220051317-782971ddf21b/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag=
|
||||
github.com/shurcooL/octicon v0.0.0-20191102190552-cbb32d6a785c h1:p3w+lTqXulfa3aDeycxmcLJDNxyUB89gf2/XqqK3eO0=
|
||||
github.com/shurcooL/octicon v0.0.0-20191102190552-cbb32d6a785c/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ=
|
||||
github.com/shurcooL/go v0.0.0-20230706063926-5fe729b41b3a h1:ZHfoO7ZJhws9NU1kzZhStUnnVQiPtDe1PzpUnc6HirU=
|
||||
github.com/shurcooL/go v0.0.0-20230706063926-5fe729b41b3a/go.mod h1:DNrlr0AR9NsHD/aoc2pPeu4uSBZ/71yCHkR42yrzW3M=
|
||||
github.com/shurcooL/go-goon v1.0.0 h1:BCQPvxGkHHJ4WpBO4m/9FXbITVIsvAm/T66cCcCGI7E=
|
||||
github.com/shurcooL/go-goon v1.0.0/go.mod h1:2wTHMsGo7qnpmqA8ADYZtP4I1DD94JpXGQ3Dxq2YQ5w=
|
||||
github.com/shurcooL/highlight_diff v0.0.0-20230708024848-22f825814995 h1:/6Fa0HAouqks/nlr3C3sv7KNDqutP3CM/MYz225uO28=
|
||||
github.com/shurcooL/highlight_diff v0.0.0-20230708024848-22f825814995/go.mod h1:eqklBUMsamqZbxXhhr6GafgswFTa5Aq12VQ0I2lnCR8=
|
||||
github.com/shurcooL/highlight_go v0.0.0-20230708025100-33e05792540a h1:aMmA4ghJXuzwIS/mEK+bf7U2WZECRxa3sPgR4QHj8Hw=
|
||||
github.com/shurcooL/highlight_go v0.0.0-20230708025100-33e05792540a/go.mod h1:kLtotffsKtKsCupV8wNnNwQQHBccB1Oy5VSg8P409Go=
|
||||
github.com/shurcooL/octicon v0.0.0-20230705024016-66bff059edb8 h1:W5meM/5DP0Igf+pS3Se363Y2DoDv9LUuZgQ24uG9LNY=
|
||||
github.com/shurcooL/octicon v0.0.0-20230705024016-66bff059edb8/go.mod h1:hWBWTvIJ918VxbNOk2hxQg1/5j1M9yQI1Kp8d9qrOq8=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d h1:yKm7XZV6j9Ev6lojP2XaIshpT4ymkqhMeSghO5Ps00E=
|
||||
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE=
|
||||
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e h1:qpG93cPwA5f7s/ZPBJnGOYQNK/vKsaDaseuKT5Asee8=
|
||||
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e h1:Ee+VZw13r9NTOMnwTPs6O5KZ0MJU54hsxu9FpZ4pQ10=
|
||||
github.com/tomnomnom/xtermcolor v0.0.0-20160428124646-b78803f00a7e/go.mod h1:fSIW/szJHsRts/4U8wlMPhs+YqJC+7NYR+Qqb1uJVpA=
|
||||
github.com/urfave/cli/v2 v2.11.1 h1:UKK6SP7fV3eKOefbS87iT9YHefv7iB/53ih6e+GNAsE=
|
||||
github.com/urfave/cli/v2 v2.11.1/go.mod h1:f8iq5LtQ/bLxafbdBSLPPNsgaW0l/2fYYEHhAyPlwvo=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
||||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
|
||||
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=
|
||||
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=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539 h1:/eM0PCrQI2xd471rI+snWuu251/+/jpBpZqir2mPdnU=
|
||||
golang.org/x/image v0.0.0-20220722155232-062f8c9fd539/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/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-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-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/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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/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=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
||||
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.3.8 h1:8bEphSAB69t3odsCR4NDzt581iZEWQuRM27Cg6KgfPY=
|
||||
gorm.io/driver/postgres v1.3.8/go.mod h1:qB98Aj6AhRO/oyu/jmZsi/YM9g6UzVCjMxO/6frFvcA=
|
||||
gorm.io/driver/sqlite v1.3.6 h1:Fi8xNYCUplOqWiPa3/GuCeowRNBRGTf62DEmhMDHeQQ=
|
||||
gorm.io/driver/sqlite v1.3.6/go.mod h1:Sg1/pvnKtbQ7jLXxfZa+jSHvoX8hoZA8cn4xllOMTgE=
|
||||
gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.23.6/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.23.8 h1:h8sGJ+biDgBA1AD1Ha9gFCx7h8npU7AsLdlkX0n2TpE=
|
||||
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.6 h1:ydr9xEd5YAM0vxVDY0X139dyzNz10spDiDlC7+ibLeU=
|
||||
gorm.io/driver/postgres v1.5.6/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
|
||||
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
|
||||
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
|
||||
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
|
|
|
@ -0,0 +1,221 @@
|
|||
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
|
||||
}
|
|
@ -35,18 +35,51 @@ const (
|
|||
ScopeUserInsight = "admin.user.insights"
|
||||
ScopeUserImpersonate = "admin.user.impersonate"
|
||||
ScopeUserBan = "admin.user.ban"
|
||||
ScopeUserPromote = "admin.user.promote"
|
||||
ScopeUserDelete = "admin.user.delete"
|
||||
ScopeUserPromote = "admin.user.promote"
|
||||
|
||||
// Other admin views
|
||||
ScopeFeedbackAndReports = "admin.feedback"
|
||||
ScopeChangeLog = "admin.changelog"
|
||||
ScopeUserNotes = "admin.user.notes"
|
||||
|
||||
// Admins with this scope can not be blocked by users.
|
||||
ScopeUnblockable = "admin.unblockable"
|
||||
|
||||
// Special scope to mark an admin automagically in the Inner Circle
|
||||
ScopeIsInnerCircle = "admin.override.inner-circle"
|
||||
|
||||
// The global wildcard scope gets all available permissions.
|
||||
ScopeSuperuser = "*"
|
||||
)
|
||||
|
||||
// Friendly description for each scope.
|
||||
var AdminScopeDescriptions = map[string]string{
|
||||
ScopeChatModerator: "Have operator controls in the chat room (can mark cameras as explicit, or kick/ban people from chat).",
|
||||
ScopeForumModerator: "Ability to moderate the forum (edit or delete posts).",
|
||||
ScopePhotoModerator: "Ability to moderate photo galleries (can see all private or friends-only photos, and edit or delete them).",
|
||||
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 = 16
|
||||
const QuantityAdminScopes = 20
|
||||
|
||||
// The specially named Superusers group.
|
||||
const AdminGroupSuperusers = "Superusers"
|
||||
|
@ -63,12 +96,20 @@ 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]
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import (
|
|||
// returned by the scope list function.
|
||||
func TestAdminScopesCount(t *testing.T) {
|
||||
var scopes = config.ListAdminScopes()
|
||||
if len(scopes) != config.QuantityAdminScopes {
|
||||
if len(scopes) != config.QuantityAdminScopes || len(scopes) != len(config.AdminScopeDescriptions) {
|
||||
t.Errorf(
|
||||
"The list of scopes returned by ListAdminScopes doesn't match the expected count. "+
|
||||
"Expected %d, got %d",
|
||||
|
|
|
@ -66,6 +66,13 @@ 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
|
||||
|
||||
|
@ -74,7 +81,7 @@ const (
|
|||
)
|
||||
|
||||
var (
|
||||
UsernameRegexp = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
|
||||
UsernameRegexp = regexp.MustCompile(`^[a-z0-9_.-]{3,32}$`)
|
||||
ReservedUsernames = []string{
|
||||
"admin",
|
||||
"admins",
|
||||
|
@ -94,6 +101,7 @@ var (
|
|||
const (
|
||||
MaxPhotoWidth = 1280
|
||||
ProfilePhotoWidth = 512
|
||||
AltTextMaxLength = 5000
|
||||
|
||||
// Quotas for uploaded photos.
|
||||
PhotoQuotaUncertified = 6
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package config
|
||||
|
||||
import "regexp"
|
||||
|
||||
// Various hard-coded enums such as choice of gender, sexuality, relationship status etc.
|
||||
var (
|
||||
MaritalStatus = []string{
|
||||
|
@ -32,6 +34,8 @@ var (
|
|||
"Gay",
|
||||
"Bisexual",
|
||||
"Bicurious",
|
||||
"Pansexual",
|
||||
"Asexual",
|
||||
}
|
||||
|
||||
HereFor = []string{
|
||||
|
@ -103,6 +107,12 @@ 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.
|
||||
|
|
|
@ -15,7 +15,8 @@ var (
|
|||
PageSizePrivatePhotoGrantees = 12
|
||||
PageSizeAdminCertification = 20
|
||||
PageSizeAdminFeedback = 20
|
||||
PageSizeAdminFeedbackNotesPage = 5 // feedback on User Notes page
|
||||
PageSizeAdminFeedbackNotesPage = 5 // feedback on User Notes page
|
||||
PageSizeChangeLog = 20
|
||||
PageSizeAdminUserNotes = 10 // other users' notes
|
||||
PageSizeSiteGallery = 16
|
||||
PageSizeUserGallery = 16
|
||||
|
|
|
@ -43,6 +43,9 @@ func Dashboard() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Parse notification filters.
|
||||
nf := models.NewNotificationFilterFromForm(r)
|
||||
|
||||
// Get our notifications.
|
||||
pager := &models.Pagination{
|
||||
Page: 1,
|
||||
|
@ -50,7 +53,7 @@ func Dashboard() http.HandlerFunc {
|
|||
Sort: "created_at desc",
|
||||
}
|
||||
pager.ParsePage(r)
|
||||
notifs, err := models.PaginateNotifications(currentUser, pager)
|
||||
notifs, err := models.PaginateNotifications(currentUser, nf, pager)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't get your notifications: %s", err)
|
||||
}
|
||||
|
@ -86,6 +89,7 @@ func Dashboard() http.HandlerFunc {
|
|||
var vars = map[string]interface{}{
|
||||
"Notifications": notifs,
|
||||
"NotifMap": notifMap,
|
||||
"Filters": nf,
|
||||
"Pager": pager,
|
||||
|
||||
// Show a warning to 'restricted' profiles who are especially private.
|
||||
|
|
|
@ -4,6 +4,8 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
|
@ -41,6 +43,14 @@ func Deactivate() http.HandlerFunc {
|
|||
session.LogoutUser(w, r)
|
||||
session.Flash(w, r, "Your account has been deactivated and you are now logged out. If you wish to re-activate your account, sign in again with your username and password.")
|
||||
templates.Redirect(w, "/")
|
||||
|
||||
// Maybe kick them from chat if this deletion makes them into a Shy Account.
|
||||
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
|
||||
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
|
||||
}
|
||||
|
||||
// Log the change.
|
||||
models.LogEvent(currentUser, nil, models.ChangeLogLifecycle, "users", currentUser.ID, "Deactivated their account.")
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -78,5 +88,8 @@ func Reactivate() http.HandlerFunc {
|
|||
|
||||
session.Flash(w, r, "Welcome back! Your account has been reactivated.")
|
||||
templates.Redirect(w, "/")
|
||||
|
||||
// Log the change.
|
||||
models.LogEvent(currentUser, nil, models.ChangeLogLifecycle, "users", currentUser.ID, "Reactivated their account.")
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/models/deletion"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
|
@ -40,6 +44,14 @@ func Delete() http.HandlerFunc {
|
|||
session.LogoutUser(w, r)
|
||||
session.Flash(w, r, "Your account has been deleted.")
|
||||
templates.Redirect(w, "/")
|
||||
|
||||
// Kick them from the chat room if they are online.
|
||||
if _, err := chat.DisconnectUserNow(currentUser, "You have been signed out of chat because you had deleted your account."); err != nil {
|
||||
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
|
||||
}
|
||||
|
||||
// Log the change.
|
||||
models.LogDeleted(nil, nil, "users", currentUser.ID, fmt.Sprintf("Username %s has deleted their account.", currentUser.Username), nil)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,6 @@ package account
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
|
@ -10,18 +9,12 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
var UserFriendsRegexp = regexp.MustCompile(`^/friends/u/([^@]+?)$`)
|
||||
|
||||
// User friends page (/friends/u/username)
|
||||
func UserFriends() http.HandlerFunc {
|
||||
tmpl := templates.Must("account/friends.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse the username out of the URL parameters.
|
||||
var username string
|
||||
m := UserFriendsRegexp.FindStringSubmatch(r.URL.Path)
|
||||
if m != nil {
|
||||
username = m[1]
|
||||
}
|
||||
var username = r.PathValue("username")
|
||||
|
||||
// Find this user.
|
||||
user, err := models.FindUser(username)
|
||||
|
|
|
@ -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, "/photo/u/"+user.Username)
|
||||
templates.Redirect(w, "/u/"+user.Username+"/photos")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,6 @@ package account
|
|||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/middleware"
|
||||
|
@ -13,18 +12,12 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/worker"
|
||||
)
|
||||
|
||||
var ProfileRegexp = regexp.MustCompile(`^/u/([^@]+?)$`)
|
||||
|
||||
// User profile page (/u/username)
|
||||
func Profile() http.HandlerFunc {
|
||||
tmpl := templates.Must("account/profile.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse the username out of the URL parameters.
|
||||
var username string
|
||||
m := ProfileRegexp.FindStringSubmatch(r.URL.Path)
|
||||
if m != nil {
|
||||
username = m[1]
|
||||
}
|
||||
var username = r.PathValue("username")
|
||||
|
||||
// Find this user.
|
||||
user, err := models.FindUser(username)
|
||||
|
@ -86,14 +79,9 @@ func Profile() http.HandlerFunc {
|
|||
|
||||
var isSelf = currentUser.ID == user.ID
|
||||
|
||||
// Banned or disabled? Only admin can view then.
|
||||
if user.Status != models.UserStatusActive && !currentUser.IsAdmin {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Is either one blocking?
|
||||
if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
|
||||
// Give a Not Found page if we can not see this user.
|
||||
if err := user.CanBeSeenBy(currentUser); err != nil {
|
||||
log.Error("%s can not be seen by viewer %s: %s", user.Username, currentUser.Username, err)
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
|
@ -114,20 +102,14 @@ 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),
|
||||
"ForumThreadCount": models.CountThreadsByUser(user),
|
||||
"ForumReplyCount": models.CountCommentsByUser(user, "threads"),
|
||||
"PhotoCommentCount": models.CountCommentsByUser(user, "photos"),
|
||||
"CommentsReceivedCount": models.CountCommentsReceived(user),
|
||||
"LikesGivenCount": models.CountLikesGiven(user),
|
||||
"LikesReceivedCount": models.CountLikesReceived(user),
|
||||
"OnChat": worker.GetChatStatistics().IsOnline(user.Username),
|
||||
"User": user,
|
||||
"LikeMap": likeMap,
|
||||
"IsFriend": isFriend,
|
||||
"IsPrivate": isPrivate,
|
||||
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
|
||||
"NoteCount": models.CountNotesAboutUser(currentUser, user),
|
||||
"FriendCount": models.CountFriends(user.ID),
|
||||
"OnChat": worker.GetChatStatistics().IsOnline(user.Username),
|
||||
|
||||
// Details on who likes their profile page.
|
||||
"LikeExample": likeExample,
|
||||
|
|
|
@ -96,12 +96,14 @@ 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, "Couldn't search users: %s", err)
|
||||
session.FlashError(w, r, "An error has occurred: %s.", err)
|
||||
}
|
||||
|
||||
// Who's Nearby feature, get some data.
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/geoip"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
|
@ -16,8 +17,10 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/redis"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/spam"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
"code.nonshy.com/nonshy/website/pkg/utility"
|
||||
"code.nonshy.com/nonshy/website/pkg/worker"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
|
@ -50,6 +53,10 @@ 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
|
||||
|
||||
|
@ -108,7 +115,15 @@ func Settings() http.HandlerFunc {
|
|||
|
||||
// Set profile attributes.
|
||||
for _, attr := range config.ProfileFields {
|
||||
user.SetProfileField(attr, r.PostFormValue(attr))
|
||||
var value = strings.TrimSpace(r.PostFormValue(attr))
|
||||
|
||||
// Look for spammy links to restricted video sites or things.
|
||||
if err := spam.DetectSpamMessage(value); err != nil {
|
||||
session.FlashError(w, r, "On field '%s': %s", attr, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
user.SetProfileField(attr, value)
|
||||
}
|
||||
|
||||
// "Looking For" checkbox list.
|
||||
|
@ -167,6 +182,7 @@ func Settings() http.HandlerFunc {
|
|||
for _, field := range []string{
|
||||
"hero-text-dark",
|
||||
"card-lightness",
|
||||
"website-theme",
|
||||
} {
|
||||
value := r.PostFormValue(field)
|
||||
user.SetProfileField(field, value)
|
||||
|
@ -293,32 +309,91 @@ func Settings() http.HandlerFunc {
|
|||
case "settings":
|
||||
hashtag = "#account"
|
||||
var (
|
||||
oldPassword = r.PostFormValue("old_password")
|
||||
changeEmail = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_email")))
|
||||
password1 = strings.TrimSpace(r.PostFormValue("new_password"))
|
||||
password2 = strings.TrimSpace(r.PostFormValue("new_password2"))
|
||||
oldPassword = r.PostFormValue("old_password")
|
||||
changeEmail = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_email")))
|
||||
changeUsername = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_username")))
|
||||
password1 = strings.TrimSpace(r.PostFormValue("new_password"))
|
||||
password2 = strings.TrimSpace(r.PostFormValue("new_password2"))
|
||||
)
|
||||
|
||||
// Their old password is needed to make any changes to their account.
|
||||
if err := user.CheckPassword(oldPassword); err != nil {
|
||||
session.FlashError(w, r, "Could not make changes to your account settings as the 'current password' you entered was incorrect.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
templates.Redirect(w, r.URL.Path+hashtag)
|
||||
return
|
||||
}
|
||||
|
||||
// Changing their username?
|
||||
if changeUsername != user.Username {
|
||||
// Not if they are in the chat room!
|
||||
if isOnChat {
|
||||
session.FlashError(w, r, "Your username could not be changed right now because you are logged into the chat room. Please exit the chat room, wait a minute, and try your request again.")
|
||||
templates.Redirect(w, r.URL.Path+hashtag)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the new name is OK.
|
||||
if err := models.IsValidUsername(changeUsername); err != nil {
|
||||
session.FlashError(w, r, "Could not change your username: %s", err.Error())
|
||||
templates.Redirect(w, r.URL.Path+hashtag)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear their history on the chat room.
|
||||
go func(username string) {
|
||||
log.Error("Change of username, clear chat history for old name %s", username)
|
||||
i, err := chat.EraseChatHistory(username)
|
||||
if err != nil {
|
||||
log.Error("EraseChatHistory(%s): %s", username, err)
|
||||
return
|
||||
}
|
||||
|
||||
session.Flash(w, r, "Notice: due to your recent change in username, your direct message history on the Chat Room has been reset. %d message(s) had been removed.", i)
|
||||
}(user.Username)
|
||||
|
||||
// Set their name.
|
||||
origUsername := user.Username
|
||||
user.Username = changeUsername
|
||||
if err := user.Save(); err != nil {
|
||||
session.FlashError(w, r, "Error saving your new username: %s", err)
|
||||
} else {
|
||||
session.Flash(w, r, "Your username has been updated to: %s", user.Username)
|
||||
|
||||
// Notify the admin about this to keep tabs if someone is acting strangely
|
||||
// with too-frequent username changes.
|
||||
fb := &models.Feedback{
|
||||
Intent: "report",
|
||||
Subject: "Change of username",
|
||||
UserID: user.ID,
|
||||
TableName: "users",
|
||||
TableID: user.ID,
|
||||
Message: fmt.Sprintf(
|
||||
"A user has modified their username on their profile page!\n\n"+
|
||||
"* Original: %s\n* Updated: %s",
|
||||
origUsername, changeUsername,
|
||||
),
|
||||
}
|
||||
|
||||
// Save the feedback.
|
||||
if err := models.CreateFeedback(fb); err != nil {
|
||||
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Changing their email?
|
||||
if changeEmail != user.Email {
|
||||
// Validate the email.
|
||||
if _, err := nm.ParseAddress(changeEmail); err != nil {
|
||||
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
templates.Redirect(w, r.URL.Path+hashtag)
|
||||
return
|
||||
}
|
||||
|
||||
// Email must not already exist.
|
||||
if _, err := models.FindUser(changeEmail); err == nil {
|
||||
session.FlashError(w, r, "That email address is already in use.")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
templates.Redirect(w, r.URL.Path+hashtag)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -330,7 +405,7 @@ func Settings() http.HandlerFunc {
|
|||
}
|
||||
if err := redis.Set(fmt.Sprintf(config.ChangeEmailRedisKey, token.Token), token, config.SignupTokenExpires); err != nil {
|
||||
session.FlashError(w, r, "Failed to create change email token: %s", err)
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
templates.Redirect(w, r.URL.Path+hashtag)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -374,6 +449,11 @@ 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
|
||||
}
|
||||
|
|
|
@ -60,7 +60,6 @@ func Signup() http.HandlerFunc {
|
|||
}
|
||||
|
||||
var token SignupToken
|
||||
log.Info("SignupToken: %s", tokenStr)
|
||||
if tokenStr != "" {
|
||||
// Validate it.
|
||||
if err := redis.Get(fmt.Sprintf(config.SignupTokenRedisKey, tokenStr), &token); err != nil || token.Token != tokenStr {
|
||||
|
@ -86,6 +85,9 @@ 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.
|
||||
|
@ -95,27 +97,10 @@ func Signup() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Reserved username check.
|
||||
for _, cmp := range config.ReservedUsernames {
|
||||
if username == cmp {
|
||||
session.FlashError(w, r, "That username is reserved, please choose a different username.")
|
||||
templates.Redirect(w, r.URL.Path+"?token="+tokenStr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Cache username in case of passwd validation errors.
|
||||
vars["Email"] = email
|
||||
vars["Username"] = username
|
||||
|
||||
// Is the app not configured to send email?
|
||||
if !config.Current.Mail.Enabled {
|
||||
session.FlashError(w, r, "This app is not configured to send email so you can not sign up at this time. "+
|
||||
"Please contact the website administrator about this issue!")
|
||||
templates.Redirect(w, r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the email.
|
||||
if _, err := nm.ParseAddress(email); err != nil {
|
||||
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
|
||||
|
@ -163,6 +148,16 @@ 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",
|
||||
|
@ -177,6 +172,10 @@ 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
|
||||
}
|
||||
|
@ -205,7 +204,6 @@ func Signup() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Full sign-up step (w/ email verification token), validate more things.
|
||||
var hasError bool
|
||||
if len(password) < 3 {
|
||||
session.FlashError(w, r, "Please enter a password longer than 3 characters.")
|
||||
hasError = true
|
||||
|
@ -214,8 +212,9 @@ func Signup() http.HandlerFunc {
|
|||
hasError = true
|
||||
}
|
||||
|
||||
if !config.UsernameRegexp.MatchString(username) {
|
||||
session.FlashError(w, r, "Your username must consist of only numbers, letters, - . and be 3-32 characters.")
|
||||
// Validate the username is OK: well formatted, not reserved, not existing.
|
||||
if err := models.IsValidUsername(username); err != nil {
|
||||
session.FlashError(w, r, err.Error())
|
||||
hasError = true
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,6 @@ package account
|
|||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
|
@ -14,18 +13,12 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
var NotesURLRegexp = regexp.MustCompile(`^/notes/u/([^@]+?)$`)
|
||||
|
||||
// User notes page (/notes/u/username)
|
||||
func UserNotes() http.HandlerFunc {
|
||||
tmpl := templates.Must("account/user_notes.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse the username out of the URL parameters.
|
||||
var username string
|
||||
m := NotesURLRegexp.FindStringSubmatch(r.URL.Path)
|
||||
if m != nil {
|
||||
username = m[1]
|
||||
}
|
||||
var username = r.PathValue("username")
|
||||
|
||||
// Find this user.
|
||||
user, err := models.FindUser(username)
|
||||
|
@ -208,7 +201,7 @@ func MyNotes() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Admin notes?
|
||||
if adminNotes && !currentUser.IsAdmin {
|
||||
if adminNotes && !currentUser.HasAdminScope(config.ScopeUserNotes) {
|
||||
adminNotes = false
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,128 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
// ChangeLog controller (/admin/changelog)
|
||||
func ChangeLog() http.HandlerFunc {
|
||||
tmpl := templates.Must("admin/change_log.html")
|
||||
|
||||
// Whitelist for ordering options.
|
||||
var sortWhitelist = []string{
|
||||
"created_at desc",
|
||||
"created_at asc",
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Query parameters.
|
||||
var (
|
||||
tableName = r.FormValue("table_name")
|
||||
tableID uint64
|
||||
aboutUserID uint64
|
||||
aboutUser = r.FormValue("about_user_id")
|
||||
adminUserID uint64
|
||||
adminUser = r.FormValue("admin_user_id")
|
||||
event = r.FormValue("event")
|
||||
sort = r.FormValue("sort")
|
||||
searchQuery = r.FormValue("search")
|
||||
search = models.ParseSearchString(searchQuery)
|
||||
sortOK bool
|
||||
)
|
||||
|
||||
// Sort options.
|
||||
for _, v := range sortWhitelist {
|
||||
if sort == v {
|
||||
sortOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !sortOK {
|
||||
sort = "created_at desc"
|
||||
}
|
||||
|
||||
if i, err := strconv.Atoi(r.FormValue("table_id")); err == nil {
|
||||
tableID = uint64(i)
|
||||
}
|
||||
|
||||
// User IDs can be string values to look up by username or email address.
|
||||
if aboutUser != "" {
|
||||
if i, err := strconv.Atoi(aboutUser); err == nil {
|
||||
aboutUserID = uint64(i)
|
||||
} else {
|
||||
if user, err := models.FindUser(aboutUser); err == nil {
|
||||
aboutUserID = user.ID
|
||||
} else {
|
||||
session.FlashError(w, r, "Couldn't find About User ID: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if adminUser != "" {
|
||||
if i, err := strconv.Atoi(adminUser); err == nil {
|
||||
adminUserID = uint64(i)
|
||||
} else {
|
||||
if user, err := models.FindUser(adminUser); err == nil {
|
||||
adminUserID = user.ID
|
||||
} else {
|
||||
session.FlashError(w, r, "Couldn't find Admin User ID: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pager := &models.Pagination{
|
||||
PerPage: config.PageSizeChangeLog,
|
||||
Sort: sort,
|
||||
}
|
||||
pager.ParsePage(r)
|
||||
|
||||
cl, err := models.PaginateChangeLog(tableName, tableID, aboutUserID, adminUserID, event, search, pager)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Error paginating the change log: %s", err)
|
||||
}
|
||||
|
||||
// Map the various user IDs.
|
||||
var (
|
||||
userIDs = []uint64{}
|
||||
)
|
||||
for _, row := range cl {
|
||||
if row.AboutUserID > 0 {
|
||||
userIDs = append(userIDs, row.AboutUserID)
|
||||
}
|
||||
if row.AdminUserID > 0 {
|
||||
userIDs = append(userIDs, row.AdminUserID)
|
||||
}
|
||||
}
|
||||
userMap, err := models.MapUsers(nil, userIDs)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Error mapping user IDs: %s", err)
|
||||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"ChangeLog": cl,
|
||||
"TableNames": models.ChangeLogTables(),
|
||||
"EventTypes": models.ChangeLogEventTypes,
|
||||
"Pager": pager,
|
||||
"UserMap": userMap,
|
||||
|
||||
// Filters
|
||||
"TableName": tableName,
|
||||
"TableID": tableID,
|
||||
"AboutUserID": aboutUser,
|
||||
"AdminUserID": adminUser,
|
||||
"Event": event,
|
||||
"SearchQuery": searchQuery,
|
||||
"Sort": sort,
|
||||
}
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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, "/photo/u/"+user.Username+"?visibility=public")
|
||||
templates.Redirect(w, "/u/"+user.Username+"/photos?visibility=public")
|
||||
} else {
|
||||
templates.Redirect(w, "/u/"+user.Username)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
// Admin transparency page that lists the scopes and permissions an admin account has for all to see.
|
||||
func Transparency() http.HandlerFunc {
|
||||
tmpl := templates.Must("admin/transparency.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
username = r.PathValue("username")
|
||||
)
|
||||
|
||||
// Get this user.
|
||||
user, err := models.FindUser(username)
|
||||
if err != nil {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Only for admin user accounts.
|
||||
if !user.IsAdmin {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Template variables.
|
||||
var vars = map[string]interface{}{
|
||||
"User": user,
|
||||
"AdminScopes": config.ListAdminScopes(),
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
|
@ -1,11 +1,14 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/models/deletion"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
|
@ -24,6 +27,14 @@ func MarkPhotoExplicit() http.HandlerFunc {
|
|||
next = "/"
|
||||
}
|
||||
|
||||
// Get current user.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Failed to get current user: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
if idInt, err := strconv.Atoi(r.FormValue("photo_id")); err == nil {
|
||||
photoID = uint64(idInt)
|
||||
} else {
|
||||
|
@ -46,6 +57,12 @@ 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)
|
||||
})
|
||||
}
|
||||
|
@ -141,6 +158,14 @@ func UserActions() http.HandlerFunc {
|
|||
user.Save()
|
||||
session.Flash(w, r, "User ban status updated!")
|
||||
templates.Redirect(w, "/u/"+user.Username)
|
||||
|
||||
// Maybe kick them from chat room now.
|
||||
if _, err := chat.MaybeDisconnectUser(user); err != nil {
|
||||
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
|
||||
}
|
||||
|
||||
// Log the change.
|
||||
models.LogEvent(user, currentUser, models.ChangeLogBanned, "users", currentUser.ID, fmt.Sprintf("User ban status updated to: %s", status))
|
||||
return
|
||||
}
|
||||
case "promote":
|
||||
|
@ -157,6 +182,9 @@ 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":
|
||||
|
@ -174,6 +202,14 @@ func UserActions() http.HandlerFunc {
|
|||
session.Flash(w, r, "User has been deleted!")
|
||||
}
|
||||
templates.Redirect(w, "/admin")
|
||||
|
||||
// Kick them from the chat room if they are online.
|
||||
if _, err := chat.DisconnectUserNow(user, "You have been signed out of chat because your account has been deleted."); err != nil {
|
||||
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
|
||||
}
|
||||
|
||||
// Log the change.
|
||||
models.LogDeleted(nil, currentUser, "users", user.ID, fmt.Sprintf("Username %s has been deleted by an admin.", user.Username), nil)
|
||||
return
|
||||
}
|
||||
default:
|
||||
|
|
|
@ -96,25 +96,28 @@ func Likes() http.HandlerFunc {
|
|||
case "photos":
|
||||
if photo, err := models.GetPhoto(tableID); err == nil {
|
||||
if user, err := models.GetUser(photo.UserID); err == nil {
|
||||
// Admin safety check: in case the admin clicked 'Like' on a friends-only or private
|
||||
// picture they shouldn't have been expected to see, do not log a like.
|
||||
if currentUser.IsAdmin && currentUser.ID != user.ID {
|
||||
// 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 {
|
||||
if (photo.Visibility == models.PhotoFriends && !models.AreFriends(user.ID, currentUser.ID)) ||
|
||||
(photo.Visibility == models.PhotoPrivate && !models.IsPrivateUnlocked(user.ID, currentUser.ID)) {
|
||||
SendJSON(w, http.StatusForbidden, Response{
|
||||
Error: "You are not allowed to like that photo.",
|
||||
})
|
||||
return
|
||||
unallowed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Blocking safety check: if either user blocks the other, liking is not allowed.
|
||||
if models.IsBlocking(currentUser.ID, user.ID) {
|
||||
unallowed = true
|
||||
}
|
||||
|
||||
if unallowed {
|
||||
SendJSON(w, http.StatusForbidden, Response{
|
||||
Error: "You are not allowed to like that photo.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
targetUser = user
|
||||
}
|
||||
} else {
|
||||
|
@ -175,7 +178,7 @@ func Likes() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Remove the target's notification about this like.
|
||||
models.RemoveSpecificNotification(targetUser.ID, models.NotificationLike, req.TableName, tableID)
|
||||
models.RemoveSpecificNotificationAboutUser(targetUser.ID, currentUser.ID, models.NotificationLike, req.TableName, tableID)
|
||||
} else {
|
||||
if err := models.AddLike(currentUser, req.TableName, tableID); err != nil {
|
||||
SendJSON(w, http.StatusBadRequest, Response{
|
||||
|
|
|
@ -0,0 +1,131 @@
|
|||
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,
|
||||
})
|
||||
})
|
||||
}
|
|
@ -97,6 +97,9 @@ func BlockUser() http.HandlerFunc {
|
|||
session.FlashError(w, r, "Couldn't unblock this user: %s.", err)
|
||||
} else {
|
||||
session.Flash(w, r, "You have removed %s from your block list.", user.Username)
|
||||
|
||||
// Log the change.
|
||||
models.LogDeleted(currentUser, nil, "blocks", user.ID, "Unblocked user "+user.Username+".", nil)
|
||||
}
|
||||
templates.Redirect(w, "/users/blocked")
|
||||
return
|
||||
|
@ -139,6 +142,9 @@ func BlockUser() http.HandlerFunc {
|
|||
session.FlashError(w, r, "Couldn't block this user: %s.", err)
|
||||
} else {
|
||||
session.Flash(w, r, "You have added %s to your block list.", user.Username)
|
||||
|
||||
// Log the change.
|
||||
models.LogCreated(currentUser, "blocks", user.ID, "Blocks user "+user.Username+".")
|
||||
}
|
||||
|
||||
// Sync the block to the BareRTC chat server now, in case either user is currently online.
|
||||
|
|
|
@ -153,6 +153,10 @@ 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
|
||||
|
|
|
@ -117,6 +117,9 @@ 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
|
||||
|
@ -151,6 +154,9 @@ func PostComment() http.HandlerFunc {
|
|||
session.FlashError(w, r, "Couldn't save comment: %s", err)
|
||||
} else {
|
||||
session.Flash(w, r, "Comment updated!")
|
||||
|
||||
// Log the change.
|
||||
models.LogUpdated(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, "Updated a comment.\n\n---\n\n"+comment.Message, nil)
|
||||
}
|
||||
templates.Redirect(w, fromURL)
|
||||
return
|
||||
|
@ -168,6 +174,9 @@ 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{
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package forum
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
@ -64,16 +65,29 @@ 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
|
||||
|
@ -81,11 +95,15 @@ 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)
|
||||
|
@ -114,11 +132,36 @@ 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)
|
||||
|
|
|
@ -4,7 +4,6 @@ import (
|
|||
"net/http"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
|
@ -16,21 +15,16 @@ func Forum() http.HandlerFunc {
|
|||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse the path parameters
|
||||
var (
|
||||
forum *models.Forum
|
||||
fragment = r.PathValue("fragment")
|
||||
forum *models.Forum
|
||||
)
|
||||
|
||||
if m := ForumPathRegexp.FindStringSubmatch(r.URL.Path); m == nil {
|
||||
log.Error("Regexp failed to parse: %s", r.URL.Path)
|
||||
// Look up the forum by its fragment.
|
||||
if found, err := models.ForumByFragment(fragment); err != nil {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
} else {
|
||||
// Look up the forum itself.
|
||||
if found, err := models.ForumByFragment(m[1]); err != nil {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
} else {
|
||||
forum = found
|
||||
}
|
||||
forum = found
|
||||
}
|
||||
|
||||
// Get the current user.
|
||||
|
@ -47,6 +41,12 @@ 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 {
|
||||
|
|
|
@ -17,17 +17,6 @@ var (
|
|||
FragmentRegexp = regexp.MustCompile(
|
||||
fmt.Sprintf(`^(%s)$`, FragmentPattern),
|
||||
)
|
||||
|
||||
// Forum path parameters.
|
||||
ForumPathRegexp = regexp.MustCompile(
|
||||
fmt.Sprintf(`^/f/(%s)`, FragmentPattern),
|
||||
)
|
||||
ForumPostRegexp = regexp.MustCompile(
|
||||
fmt.Sprintf(`^/f/(%s)/(post)`, FragmentPattern),
|
||||
)
|
||||
ForumThreadRegexp = regexp.MustCompile(
|
||||
fmt.Sprintf(`^/f/(%s)/(thread)/(\d+)`, FragmentPattern),
|
||||
)
|
||||
)
|
||||
|
||||
// Landing page for forums.
|
||||
|
|
|
@ -15,6 +15,7 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/photo"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/spam"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
|
@ -161,6 +162,11 @@ func NewPost() http.HandlerFunc {
|
|||
session.FlashError(w, r, "Error deleting your post: %s", err)
|
||||
} else {
|
||||
session.Flash(w, r, "Your post has been deleted.")
|
||||
|
||||
// Log the change.
|
||||
models.LogDeleted(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, fmt.Sprintf(
|
||||
"Deleted a forum comment on thread %d forum /f/%s", thread.ID, forum.Fragment,
|
||||
), comment)
|
||||
}
|
||||
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
||||
return
|
||||
|
@ -178,6 +184,19 @@ func NewPost() http.HandlerFunc {
|
|||
|
||||
// Submitting the form.
|
||||
if r.Method == http.MethodPost {
|
||||
// Look for spammy links to video sites or things.
|
||||
if err := spam.DetectSpamMessage(title + message); err != nil {
|
||||
session.FlashError(w, r, err.Error())
|
||||
if thread != nil {
|
||||
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
||||
} else if forum != nil {
|
||||
templates.Redirect(w, fmt.Sprintf("/f/%s", forum.Fragment))
|
||||
} else {
|
||||
templates.Redirect(w, "/forum")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Polls: parse form parameters into a neat list of answers.
|
||||
pollExpires, _ = strconv.Atoi(r.FormValue("poll_expires"))
|
||||
var distinctPollChoices = map[string]interface{}{}
|
||||
|
@ -315,6 +334,14 @@ func NewPost() http.HandlerFunc {
|
|||
session.FlashError(w, r, "Couldn't save comment: %s", err)
|
||||
} else {
|
||||
session.Flash(w, r, "Comment updated!")
|
||||
|
||||
// Log the change.
|
||||
models.LogUpdated(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, fmt.Sprintf(
|
||||
"Edited their comment on thread %d (in /f/%s):\n\n%s",
|
||||
thread.ID,
|
||||
forum.Fragment,
|
||||
message,
|
||||
), nil)
|
||||
}
|
||||
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
||||
return
|
||||
|
@ -327,6 +354,13 @@ func NewPost() http.HandlerFunc {
|
|||
} else {
|
||||
session.Flash(w, r, "Reply added to the thread!")
|
||||
|
||||
// Log the change.
|
||||
models.LogCreated(currentUser, "comments", reply.ID, fmt.Sprintf(
|
||||
"Commented on thread %d:\n\n%s",
|
||||
thread.ID,
|
||||
message,
|
||||
))
|
||||
|
||||
// If we're attaching a photo, link it to this reply CommentID.
|
||||
if commentPhoto != nil {
|
||||
commentPhoto.CommentID = reply.ID
|
||||
|
@ -358,7 +392,7 @@ func NewPost() http.HandlerFunc {
|
|||
TableName: "threads",
|
||||
TableID: thread.ID,
|
||||
Message: message,
|
||||
Link: fmt.Sprintf("/forum/thread/%d%s#p%d", thread.ID, queryString, reply.ID),
|
||||
Link: fmt.Sprintf("/go/comment?id=%d", reply.ID),
|
||||
}
|
||||
if err := models.CreateNotification(notif); err != nil {
|
||||
log.Error("Couldn't create thread reply notification for subscriber %d: %s", userID, err)
|
||||
|
@ -428,6 +462,18 @@ func NewPost() http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// Log the change.
|
||||
models.LogCreated(currentUser, "threads", thread.ID, fmt.Sprintf(
|
||||
"Started a new forum thread on forum /f/%s (%s)\n\n"+
|
||||
"* Has poll? %v\n"+
|
||||
"* Title: %s\n\n%s",
|
||||
forum.Fragment,
|
||||
forum.Title,
|
||||
isPoll,
|
||||
thread.Title,
|
||||
message,
|
||||
))
|
||||
|
||||
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
|
||||
return
|
||||
}
|
||||
|
|
|
@ -14,6 +14,11 @@ 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 {
|
||||
|
@ -29,7 +34,7 @@ func Newest() http.HandlerFunc {
|
|||
}
|
||||
pager.ParsePage(r)
|
||||
|
||||
posts, err := models.PaginateRecentPosts(currentUser, config.ForumCategories, pager)
|
||||
posts, err := models.PaginateRecentPosts(currentUser, config.ForumCategories, allComments, pager)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
|
@ -50,6 +55,7 @@ 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)
|
||||
|
|
|
@ -2,7 +2,6 @@ package forum
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
|
@ -12,24 +11,22 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
var ThreadPathRegexp = regexp.MustCompile(`^/forum/thread/(\d+)$`)
|
||||
|
||||
// Thread view for the comment thread body of a forum post.
|
||||
func Thread() http.HandlerFunc {
|
||||
tmpl := templates.Must("forum/thread.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse the path parameters
|
||||
var (
|
||||
idStr = r.PathValue("id")
|
||||
forum *models.Forum
|
||||
thread *models.Thread
|
||||
)
|
||||
|
||||
if m := ThreadPathRegexp.FindStringSubmatch(r.URL.Path); m == nil {
|
||||
log.Error("Regexp failed to parse: %s", r.URL.Path)
|
||||
if idStr == "" {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
} else {
|
||||
if threadID, err := strconv.Atoi(m[1]); err != nil {
|
||||
if threadID, err := strconv.Atoi(idStr); err != nil {
|
||||
session.FlashError(w, r, "Invalid thread ID in the address bar.")
|
||||
templates.Redirect(w, "/forum")
|
||||
return
|
||||
|
@ -60,6 +57,12 @@ 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)
|
||||
|
|
|
@ -60,6 +60,11 @@ func AddFriend() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
// Revoke any friends-only photo notifications they had received before.
|
||||
if err := models.RevokeFriendPhotoNotifications(currentUser, user); err != nil {
|
||||
log.Error("Couldn't revoke friend photo notifications between %s and %s: %s", currentUser.Username, user.Username, err)
|
||||
}
|
||||
|
||||
var message string
|
||||
if verdict == "reject" {
|
||||
message = fmt.Sprintf("Friend request from %s has been rejected.", username)
|
||||
|
@ -70,6 +75,12 @@ func AddFriend() http.HandlerFunc {
|
|||
session.Flash(w, r, message)
|
||||
if verdict == "reject" {
|
||||
templates.Redirect(w, "/friends?view=requests")
|
||||
|
||||
// Log the change.
|
||||
models.LogDeleted(currentUser, nil, "friends", user.ID, "Rejected friend request from "+user.Username+".", nil)
|
||||
} else {
|
||||
// Log the change.
|
||||
models.LogDeleted(currentUser, nil, "friends", user.ID, "Removed friendship with "+user.Username+".", nil)
|
||||
}
|
||||
templates.Redirect(w, "/friends")
|
||||
return
|
||||
|
@ -80,6 +91,9 @@ func AddFriend() http.HandlerFunc {
|
|||
session.Flash(w, r, "You have ignored the friend request from %s.", username)
|
||||
}
|
||||
templates.Redirect(w, "/friends")
|
||||
|
||||
// Log the change.
|
||||
models.LogUpdated(currentUser, nil, "friends", user.ID, "Ignored the friend request from "+user.Username+".", nil)
|
||||
return
|
||||
} else {
|
||||
// Post the friend request.
|
||||
|
@ -101,7 +115,13 @@ 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!")
|
||||
}
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
package htmx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/middleware"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
// Statistics and social activity on the user's profile page.
|
||||
func UserProfileActivityCard() http.HandlerFunc {
|
||||
tmpl := templates.MustLoadCustom("partials/htmx/profile_activity.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
username = r.FormValue("username")
|
||||
)
|
||||
|
||||
if username == "" {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Debug: use ?delay=true to force a slower response.
|
||||
if r.FormValue("delay") != "" {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
|
||||
// Find this user.
|
||||
user, err := models.FindUser(username)
|
||||
if err != nil {
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the current user.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "You must be signed in to view this page.")
|
||||
templates.Redirect(w, "/login?next=/u/"+url.QueryEscape(r.URL.String()))
|
||||
return
|
||||
}
|
||||
|
||||
// Is the site under a Maintenance Mode restriction?
|
||||
if middleware.MaintenanceMode(currentUser, w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Inject relationship booleans for profile picture display.
|
||||
models.SetUserRelationships(currentUser, []*models.User{user})
|
||||
|
||||
// Give a Not Found page if we can not see this user.
|
||||
if err := user.CanBeSeenBy(currentUser); err != nil {
|
||||
log.Error("%s can not be seen by viewer %s: %s", user.Username, currentUser.Username, err)
|
||||
templates.NotFoundPage(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
vars := map[string]interface{}{
|
||||
"User": user,
|
||||
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
|
||||
"FriendCount": models.CountFriends(user.ID),
|
||||
"ForumThreadCount": models.CountThreadsByUser(user),
|
||||
"ForumReplyCount": models.CountCommentsByUser(user, "threads"),
|
||||
"PhotoCommentCount": models.CountCommentsByUser(user, "photos"),
|
||||
"CommentsReceivedCount": models.CountCommentsReceived(user),
|
||||
"LikesGivenCount": models.CountLikesGiven(user),
|
||||
"LikesReceivedCount": models.CountLikesReceived(user),
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
|
@ -2,7 +2,6 @@ package inbox
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
|
@ -11,12 +10,16 @@ import (
|
|||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
var ReadURLRegexp = regexp.MustCompile(`^/messages/read/(\d+)$`)
|
||||
|
||||
// Inbox is where users receive direct messages.
|
||||
func Inbox() http.HandlerFunc {
|
||||
tmpl := templates.Must("inbox/inbox.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Message ID in path? (/messages/read/{id} endpoint)
|
||||
var msgId int
|
||||
if idStr := r.PathValue("id"); idStr != "" {
|
||||
msgId, _ = strconv.Atoi(idStr)
|
||||
}
|
||||
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Unexpected error: could not get currentUser.")
|
||||
|
@ -35,10 +38,8 @@ func Inbox() http.HandlerFunc {
|
|||
viewThread []*models.Message
|
||||
threadPager *models.Pagination
|
||||
composeToUser *models.User
|
||||
msgId int
|
||||
)
|
||||
if uri := ReadURLRegexp.FindStringSubmatch(r.URL.Path); uri != nil {
|
||||
msgId, _ = strconv.Atoi(uri[1])
|
||||
if msgId > 0 {
|
||||
if msg, err := models.GetMessage(uint64(msgId)); err != nil {
|
||||
session.FlashError(w, r, "Message not found.")
|
||||
templates.Redirect(w, "/messages")
|
||||
|
|
|
@ -2,11 +2,13 @@ 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"
|
||||
|
@ -87,6 +89,14 @@ 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
|
||||
|
@ -138,6 +148,11 @@ 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,
|
||||
|
@ -151,6 +166,26 @@ 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
|
||||
|
@ -297,6 +332,14 @@ func AdminCertification() http.HandlerFunc {
|
|||
user.Certified = false
|
||||
user.Save()
|
||||
|
||||
// Log the change.
|
||||
models.LogEvent(user, currentUser, models.ChangeLogRejected, "certification_photos", user.ID, "Rejected the certification photo with comment: "+comment)
|
||||
|
||||
// Kick them from the chat room if they are online.
|
||||
if _, err := chat.MaybeDisconnectUser(user); err != nil {
|
||||
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
|
||||
}
|
||||
|
||||
// Did we silently ignore it?
|
||||
if comment == "(ignore)" {
|
||||
session.FlashError(w, r, "The certification photo was ignored with no comment, and will not notify the sender.")
|
||||
|
@ -367,6 +410,9 @@ 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)
|
||||
|
|
|
@ -5,7 +5,9 @@ import (
|
|||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
|
@ -42,6 +44,10 @@ 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 {
|
||||
|
@ -66,7 +72,8 @@ func Edit() http.HandlerFunc {
|
|||
// Are we saving the changes?
|
||||
if r.Method == http.MethodPost {
|
||||
var (
|
||||
caption = r.FormValue("caption")
|
||||
caption = strings.TrimSpace(r.FormValue("caption"))
|
||||
altText = strings.TrimSpace(r.FormValue("alt_text"))
|
||||
isExplicit = r.FormValue("explicit") == "true"
|
||||
isGallery = r.FormValue("gallery") == "true"
|
||||
visibility = models.PhotoVisibility(r.FormValue("visibility"))
|
||||
|
@ -80,12 +87,25 @@ func Edit() http.HandlerFunc {
|
|||
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
|
||||
|
@ -116,8 +136,6 @@ func Edit() http.HandlerFunc {
|
|||
setProfilePic = false
|
||||
}
|
||||
|
||||
log.Error("SAVING PHOTO: %+v", photo)
|
||||
|
||||
if err := photo.Save(); err != nil {
|
||||
session.FlashError(w, r, "Couldn't save photo: %s", err)
|
||||
}
|
||||
|
@ -134,6 +152,14 @@ 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)
|
||||
|
@ -141,7 +167,7 @@ func Edit() http.HandlerFunc {
|
|||
}
|
||||
|
||||
// Return the user to their gallery.
|
||||
templates.Redirect(w, "/photo/u/"+currentUser.Username)
|
||||
templates.Redirect(w, "/u/"+currentUser.Username+"/photos")
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -190,6 +216,10 @@ 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 {
|
||||
|
@ -208,6 +238,13 @@ 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"
|
||||
|
@ -248,15 +285,33 @@ 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, "/photo/u/"+currentUser.Username)
|
||||
templates.Redirect(w, "/u/"+currentUser.Username+"/photos")
|
||||
return
|
||||
}
|
||||
|
||||
var vars = map[string]interface{}{
|
||||
"Photo": photo,
|
||||
"Photo": photo,
|
||||
"InnerCircleWarning": innerCircleWarning,
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, r, vars); err != nil {
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
package photo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/session"
|
||||
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||
)
|
||||
|
||||
// User endpoint to flag other photos as explicit on their behalf.
|
||||
func MarkPhotoExplicit() http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
photoID uint64
|
||||
next = r.FormValue("next")
|
||||
)
|
||||
|
||||
if !strings.HasPrefix(next, "/") {
|
||||
next = "/"
|
||||
}
|
||||
|
||||
// Get current user.
|
||||
currentUser, err := session.CurrentUser(r)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Failed to get current user: %s", err)
|
||||
templates.Redirect(w, "/")
|
||||
return
|
||||
}
|
||||
|
||||
if idInt, err := strconv.Atoi(r.FormValue("photo_id")); err == nil {
|
||||
photoID = uint64(idInt)
|
||||
} else {
|
||||
session.FlashError(w, r, "Invalid or missing photo_id parameter: %s", err)
|
||||
templates.Redirect(w, next)
|
||||
return
|
||||
}
|
||||
|
||||
// Get this photo.
|
||||
photo, err := models.GetPhoto(photoID)
|
||||
if err != nil {
|
||||
session.FlashError(w, r, "Didn't find photo ID in database: %s", err)
|
||||
templates.Redirect(w, next)
|
||||
return
|
||||
}
|
||||
|
||||
photo.Explicit = true
|
||||
if err := photo.Save(); err != nil {
|
||||
session.FlashError(w, r, "Couldn't save photo: %s", err)
|
||||
} else {
|
||||
session.Flash(w, r, "Marked photo as Explicit!")
|
||||
}
|
||||
|
||||
// Log the change.
|
||||
models.LogUpdated(&models.User{ID: photo.UserID}, currentUser, "photos", photo.ID, "Marked explicit by admin action.", []models.FieldDiff{
|
||||
models.NewFieldDiff("Explicit", false, true),
|
||||
})
|
||||
|
||||
templates.Redirect(w, next)
|
||||
})
|
||||
}
|
|
@ -42,8 +42,6 @@ func Private() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
log.Error("pager: %+v, len: %d", pager, len(users))
|
||||
|
||||
// Map reverse grantee statuses.
|
||||
var GranteeMap interface{}
|
||||
if isGrantee {
|
||||
|
@ -145,7 +143,7 @@ func Share() http.HandlerFunc {
|
|||
Type: models.NotificationPrivatePhoto,
|
||||
TableName: "__private_photos",
|
||||
TableID: currentUser.ID,
|
||||
Link: fmt.Sprintf("/photo/u/%s?visibility=private", currentUser.Username),
|
||||
Link: fmt.Sprintf("/u/%s/photos?visibility=private", currentUser.Username),
|
||||
}
|
||||
if err := models.CreateNotification(notif); err != nil {
|
||||
log.Error("Couldn't create PrivatePhoto notification: %s", err)
|
||||
|
@ -162,7 +160,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.ID); err != nil {
|
||||
if err := models.RevokePrivatePhotoNotifications(currentUser, user); err != nil {
|
||||
log.Error("RevokePrivatePhotoNotifications(%s): %s", currentUser.Username, err)
|
||||
}
|
||||
return
|
||||
|
|
|
@ -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" {
|
||||
if who != "friends" && who != "everybody" && who != "friends+private" && who != "likes" {
|
||||
// Default Who setting should be Friends-only, unless you have no friends.
|
||||
if myFriendCount > 0 {
|
||||
who = "friends"
|
||||
|
@ -94,6 +94,7 @@ 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.
|
||||
|
|
|
@ -2,10 +2,12 @@ package photo
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
|
@ -59,7 +61,8 @@ func Upload() http.HandlerFunc {
|
|||
// Are they POSTing?
|
||||
if r.Method == http.MethodPost {
|
||||
var (
|
||||
caption = r.PostFormValue("caption")
|
||||
caption = strings.TrimSpace(r.PostFormValue("caption"))
|
||||
altText = strings.TrimSpace(r.PostFormValue("alt_text"))
|
||||
isExplicit = r.PostFormValue("explicit") == "true"
|
||||
visibility = r.PostFormValue("visibility")
|
||||
isGallery = r.PostFormValue("gallery") == "true"
|
||||
|
@ -73,10 +76,14 @@ func Upload() http.HandlerFunc {
|
|||
isGallery = false
|
||||
}
|
||||
|
||||
if len(altText) > config.AltTextMaxLength {
|
||||
altText = altText[:config.AltTextMaxLength]
|
||||
}
|
||||
|
||||
// Are they at quota already?
|
||||
if photoCount >= photoQuota {
|
||||
session.FlashError(w, r, "You have too many photos to upload a new one. Please delete a photo to make room for a new one.")
|
||||
templates.Redirect(w, "/photo/u/"+user.Username)
|
||||
templates.Redirect(w, "/u/"+user.Username+"/photos")
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -134,6 +141,7 @@ func Upload() http.HandlerFunc {
|
|||
Filename: filename,
|
||||
CroppedFilename: cropFilename,
|
||||
Caption: caption,
|
||||
AltText: altText,
|
||||
Visibility: models.PhotoVisibility(visibility),
|
||||
Gallery: isGallery,
|
||||
Explicit: isExplicit,
|
||||
|
@ -159,11 +167,24 @@ func Upload() http.HandlerFunc {
|
|||
user.Save()
|
||||
}
|
||||
|
||||
// ChangeLog entry.
|
||||
models.LogCreated(user, "photos", p.ID, fmt.Sprintf(
|
||||
"Uploaded a new photo.\n\n"+
|
||||
"* Caption: %s\n"+
|
||||
"* Visibility: %s\n"+
|
||||
"* Gallery: %v\n"+
|
||||
"* Explicit: %v",
|
||||
p.Caption,
|
||||
p.Visibility,
|
||||
p.Gallery,
|
||||
p.Explicit,
|
||||
))
|
||||
|
||||
// Notify all of our friends that we posted a new picture.
|
||||
go notifyFriendsNewPhoto(p, user)
|
||||
|
||||
session.Flash(w, r, "Your photo has been uploaded successfully.")
|
||||
templates.Redirect(w, "/photo/u/"+user.Username)
|
||||
templates.Redirect(w, "/u/"+user.Username+"/photos")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,6 @@ package photo
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
|
@ -11,8 +10,6 @@ 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")
|
||||
|
@ -26,6 +23,7 @@ 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.
|
||||
|
@ -54,13 +52,6 @@ func UserPhotos() http.HandlerFunc {
|
|||
viewStyle = "cards"
|
||||
}
|
||||
|
||||
// Parse the username out of the URL parameters.
|
||||
var username string
|
||||
m := UserPhotosRegexp.FindStringSubmatch(r.URL.Path)
|
||||
if m != nil {
|
||||
username = m[1]
|
||||
}
|
||||
|
||||
// Find this user.
|
||||
user, err := models.FindUser(username)
|
||||
if err != nil {
|
||||
|
|
|
@ -22,7 +22,6 @@ func AgeGate(user *models.User, w http.ResponseWriter, r *http.Request) (handled
|
|||
"/photo/certification",
|
||||
"/photo/private",
|
||||
"/photo/view",
|
||||
"/photo/u/",
|
||||
"/comments",
|
||||
"/users/blocked",
|
||||
"/users/block",
|
||||
|
|
|
@ -46,13 +46,20 @@ 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
|
||||
|
@ -115,6 +122,11 @@ func CertRequired(handler http.Handler) http.Handler {
|
|||
return
|
||||
}
|
||||
|
||||
// Log the last visit of their current IP address.
|
||||
if err := models.PingIPAddress(r, currentUser, false); err != nil {
|
||||
log.Error("CertRequired: couldn't ping user %s IP address: %s", currentUser.Username, err)
|
||||
}
|
||||
|
||||
// Are they banned?
|
||||
if currentUser.Status == models.UserStatusBanned {
|
||||
session.LogoutUser(w, r)
|
||||
|
|
|
@ -3,6 +3,7 @@ package middleware
|
|||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
|
@ -18,6 +19,9 @@ func CSRF(handler http.Handler) http.Handler {
|
|||
token := MakeCSRFCookie(r, w)
|
||||
ctx := context.WithValue(r.Context(), session.CSRFKey, token)
|
||||
|
||||
// Store the request start time.
|
||||
ctx = context.WithValue(ctx, session.RequestTimeKey, time.Now())
|
||||
|
||||
// If it's a JSON post, allow it thru.
|
||||
if r.Header.Get("Content-Type") == "application/json" {
|
||||
handler.ServeHTTP(w, r.WithContext(ctx))
|
||||
|
|
|
@ -0,0 +1,254 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
)
|
||||
|
||||
// ChangeLog table to track updates to the database.
|
||||
type ChangeLog struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
AboutUserID uint64 `gorm:"index"`
|
||||
AdminUserID uint64 `gorm:"index"` // if an admin edits a user's item
|
||||
TableName string `gorm:"index"`
|
||||
TableID uint64 `gorm:"index"`
|
||||
Event string `gorm:"index"`
|
||||
Message string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Types of ChangeLog events.
|
||||
const (
|
||||
ChangeLogCreated = "created"
|
||||
ChangeLogUpdated = "updated"
|
||||
ChangeLogDeleted = "deleted"
|
||||
|
||||
// Certification photos.
|
||||
ChangeLogApproved = "approved"
|
||||
ChangeLogRejected = "rejected"
|
||||
|
||||
// Account status updates for easier filtering.
|
||||
ChangeLogBanned = "banned"
|
||||
ChangeLogAdmin = "admin" // admin status toggle
|
||||
ChangeLogLifecycle = "lifecycle" // de/reactivated accounts
|
||||
)
|
||||
|
||||
var ChangeLogEventTypes = []string{
|
||||
ChangeLogCreated,
|
||||
ChangeLogUpdated,
|
||||
ChangeLogDeleted,
|
||||
ChangeLogApproved,
|
||||
ChangeLogRejected,
|
||||
ChangeLogBanned,
|
||||
ChangeLogAdmin,
|
||||
ChangeLogLifecycle,
|
||||
}
|
||||
|
||||
// PaginateChangeLog lists the change logs.
|
||||
func PaginateChangeLog(tableName string, tableID, aboutUserID, adminUserID uint64, event string, search *Search, pager *Pagination) ([]*ChangeLog, error) {
|
||||
var (
|
||||
cl = []*ChangeLog{}
|
||||
where = []string{}
|
||||
placeholders = []interface{}{}
|
||||
)
|
||||
|
||||
if tableName != "" {
|
||||
where = append(where, "table_name = ?")
|
||||
placeholders = append(placeholders, tableName)
|
||||
}
|
||||
|
||||
if tableID != 0 {
|
||||
where = append(where, "table_id = ?")
|
||||
placeholders = append(placeholders, tableID)
|
||||
}
|
||||
|
||||
if aboutUserID != 0 {
|
||||
where = append(where, "about_user_id = ?")
|
||||
placeholders = append(placeholders, aboutUserID)
|
||||
}
|
||||
|
||||
if adminUserID != 0 {
|
||||
where = append(where, "admin_user_id = ?")
|
||||
placeholders = append(placeholders, adminUserID)
|
||||
}
|
||||
|
||||
if event != "" {
|
||||
where = append(where, "event = ?")
|
||||
placeholders = append(placeholders, event)
|
||||
}
|
||||
|
||||
// Text search terms
|
||||
for _, term := range search.Includes {
|
||||
var ilike = "%" + strings.ToLower(term) + "%"
|
||||
where = append(where, "change_logs.message ILIKE ?")
|
||||
placeholders = append(placeholders, ilike)
|
||||
}
|
||||
for _, term := range search.Excludes {
|
||||
var ilike = "%" + strings.ToLower(term) + "%"
|
||||
where = append(where, "change_logs.message NOT ILIKE ?")
|
||||
placeholders = append(placeholders, ilike)
|
||||
}
|
||||
|
||||
query := DB.Model(&ChangeLog{}).Where(
|
||||
strings.Join(where, " AND "),
|
||||
placeholders...,
|
||||
).Order(
|
||||
pager.Sort,
|
||||
)
|
||||
|
||||
query.Count(&pager.Total)
|
||||
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&cl)
|
||||
return cl, result.Error
|
||||
}
|
||||
|
||||
// ChangeLogTables returns all the distinct table_names appearing in the change log.
|
||||
func ChangeLogTables() []string {
|
||||
var result = []string{}
|
||||
|
||||
query := DB.Model(&ChangeLog{}).
|
||||
Select("DISTINCT change_logs.table_name").
|
||||
Group("change_logs.table_name").
|
||||
Find(&result)
|
||||
if query.Error != nil {
|
||||
log.Error("ChangeLogTables: %s", query.Error)
|
||||
}
|
||||
|
||||
sort.Strings(result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// LogEvent puts in a generic/miscellaneous change log event (e.g. certification photo updates).
|
||||
func LogEvent(aboutUser, adminUser *User, event, tableName string, tableID uint64, message string) (*ChangeLog, error) {
|
||||
cl := &ChangeLog{
|
||||
TableName: tableName,
|
||||
TableID: tableID,
|
||||
Event: event,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
if aboutUser != nil {
|
||||
cl.AboutUserID = aboutUser.ID
|
||||
}
|
||||
|
||||
if adminUser != nil && adminUser != aboutUser {
|
||||
cl.AdminUserID = adminUser.ID
|
||||
}
|
||||
|
||||
result := DB.Create(cl)
|
||||
return cl, result.Error
|
||||
}
|
||||
|
||||
// LogCreated puts in a ChangeLog "created" event.
|
||||
func LogCreated(aboutUser *User, tableName string, tableID uint64, message string) (*ChangeLog, error) {
|
||||
cl := &ChangeLog{
|
||||
TableName: tableName,
|
||||
TableID: tableID,
|
||||
Event: ChangeLogCreated,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
if aboutUser != nil {
|
||||
cl.AboutUserID = aboutUser.ID
|
||||
}
|
||||
|
||||
result := DB.Create(cl)
|
||||
return cl, result.Error
|
||||
}
|
||||
|
||||
// LogDeleted puts in a ChangeLog "deleted" event.
|
||||
func LogDeleted(aboutUser, adminUser *User, tableName string, tableID uint64, message string, original interface{}) (*ChangeLog, error) {
|
||||
// If the original model is given, JSON serialize it nicely.
|
||||
if original != nil {
|
||||
w := bytes.NewBuffer([]byte{})
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("\n", "* ")
|
||||
if err := enc.Encode(original); err != nil {
|
||||
log.Error("LogDeleted(%s %d): couldn't encode original model to JSON: %s", tableName, tableID, err)
|
||||
} else {
|
||||
message += "\n\n" + w.String()
|
||||
}
|
||||
}
|
||||
|
||||
cl := &ChangeLog{
|
||||
TableName: tableName,
|
||||
TableID: tableID,
|
||||
Event: ChangeLogDeleted,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
if aboutUser != nil {
|
||||
cl.AboutUserID = aboutUser.ID
|
||||
}
|
||||
|
||||
if adminUser != nil && adminUser != aboutUser {
|
||||
cl.AdminUserID = adminUser.ID
|
||||
}
|
||||
|
||||
result := DB.Create(cl)
|
||||
return cl, result.Error
|
||||
}
|
||||
|
||||
type FieldDiff struct {
|
||||
Key string
|
||||
Before interface{}
|
||||
After interface{}
|
||||
}
|
||||
|
||||
func NewFieldDiff(key string, before, after interface{}) FieldDiff {
|
||||
return FieldDiff{
|
||||
Key: key,
|
||||
Before: before,
|
||||
After: after,
|
||||
}
|
||||
}
|
||||
|
||||
// LogUpdated puts in a ChangeLog "updated" event.
|
||||
func LogUpdated(aboutUser, adminUser *User, tableName string, tableID uint64, message string, diffs []FieldDiff) (*ChangeLog, error) {
|
||||
// Append field diffs to the message?
|
||||
lines := []string{message}
|
||||
if len(diffs) > 0 {
|
||||
lines = append(lines, "")
|
||||
for _, row := range diffs {
|
||||
var (
|
||||
before = fmt.Sprintf("%v", row.Before)
|
||||
after = fmt.Sprintf("%v", row.After)
|
||||
)
|
||||
|
||||
if before != after {
|
||||
lines = append(lines,
|
||||
fmt.Sprintf("* **%s** changed to <code>%s</code> from <code>%s</code>",
|
||||
row.Key,
|
||||
strings.ReplaceAll(after, "`", "'"),
|
||||
strings.ReplaceAll(before, "`", "'"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cl := &ChangeLog{
|
||||
TableName: tableName,
|
||||
TableID: tableID,
|
||||
Event: ChangeLogUpdated,
|
||||
Message: strings.Join(lines, "\n"),
|
||||
}
|
||||
|
||||
if aboutUser != nil {
|
||||
cl.AboutUserID = aboutUser.ID
|
||||
}
|
||||
|
||||
if adminUser != nil && adminUser != aboutUser {
|
||||
cl.AdminUserID = adminUser.ID
|
||||
}
|
||||
|
||||
result := DB.Create(cl)
|
||||
return cl, result.Error
|
||||
}
|
|
@ -16,9 +16,9 @@ type Comment struct {
|
|||
TableName string `gorm:"index"`
|
||||
TableID uint64 `gorm:"index"`
|
||||
UserID uint64 `gorm:"index"`
|
||||
User User
|
||||
User User `json:"-"`
|
||||
Message string
|
||||
CreatedAt time.Time
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
|
|
|
@ -89,9 +89,16 @@ func MapCommentPhotos(comments []*Comment) (CommentPhotoMap, error) {
|
|||
)
|
||||
|
||||
for _, c := range comments {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
IDs = append(IDs, c.ID)
|
||||
}
|
||||
|
||||
if len(IDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
res := DB.Model(&CommentPhoto{}).Where("comment_id IN ?", IDs).Find(&ps)
|
||||
if res.Error != nil {
|
||||
return nil, res.Error
|
||||
|
|
|
@ -3,6 +3,7 @@ package deletion
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/chat"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"code.nonshy.com/nonshy/website/pkg/models"
|
||||
"code.nonshy.com/nonshy/website/pkg/photo"
|
||||
|
@ -12,6 +13,17 @@ import (
|
|||
func DeleteUser(user *models.User) error {
|
||||
log.Error("BEGIN DeleteUser(%d, %s)", user.ID, user.Username)
|
||||
|
||||
// Clear their history on the chat room.
|
||||
go func() {
|
||||
i, err := chat.EraseChatHistory(user.Username)
|
||||
if err != nil {
|
||||
log.Error("EraseChatHistory(%s): %s", user.Username, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Error("DeleteUser(%s): Cleared chat DMs history for user (%d messages erased)", user.Username, i)
|
||||
}()
|
||||
|
||||
// Remove all linked tables and assets.
|
||||
type remover struct {
|
||||
Step string
|
||||
|
@ -41,6 +53,8 @@ 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 {
|
||||
|
@ -327,3 +341,23 @@ 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
|
||||
}
|
||||
|
|
|
@ -40,7 +40,9 @@ 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)
|
||||
|
@ -383,6 +385,21 @@ func ExportUserNoteTable(zw *zip.Writer, user *models.User) error {
|
|||
return ZipJson(zw, "user_notes.json", items)
|
||||
}
|
||||
|
||||
func ExportChangeLogTable(zw *zip.Writer, user *models.User) error {
|
||||
var (
|
||||
items = []*models.ChangeLog{}
|
||||
query = models.DB.Model(&models.ChangeLog{}).Where(
|
||||
"about_user_id = ? OR admin_user_id = ?",
|
||||
user.ID, user.ID,
|
||||
).Find(&items)
|
||||
)
|
||||
if query.Error != nil {
|
||||
return query.Error
|
||||
}
|
||||
|
||||
return ZipJson(zw, "change_logs.json", items)
|
||||
}
|
||||
|
||||
func ExportUserLocationTable(zw *zip.Writer, user *models.User) error {
|
||||
var (
|
||||
items = []*models.UserLocation{}
|
||||
|
@ -412,3 +429,18 @@ 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)
|
||||
}
|
||||
|
|
|
@ -20,7 +20,8 @@ type Forum struct {
|
|||
Explicit bool `gorm:"index"`
|
||||
Privileged bool
|
||||
PermitPhotos bool
|
||||
InnerCircle bool
|
||||
InnerCircle bool `gorm:"index"`
|
||||
Private bool `gorm:"index"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
@ -101,6 +102,11 @@ 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(
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
)
|
||||
|
||||
|
@ -20,13 +22,19 @@ type RecentPost struct {
|
|||
}
|
||||
|
||||
// PaginateRecentPosts returns all of the comments on a forum paginated.
|
||||
func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]*RecentPost, error) {
|
||||
func PaginateRecentPosts(user *User, categories []string, allComments bool, pager *Pagination) ([]*RecentPost, error) {
|
||||
var (
|
||||
result = []*RecentPost{}
|
||||
query = (&Comment{}).Preload()
|
||||
blockedUserIDs = BlockedUserIDs(user)
|
||||
wheres = []string{"table_name = 'threads'"}
|
||||
|
||||
// Separate the WHERE clauses that involve forums/threads from the ones
|
||||
// that involve comments. Rationale: if the user is getting a de-duplicated
|
||||
// thread view, we'll end up running two queries - one to get all threads and
|
||||
// another to get the latest comments, and the WHERE clauses need to be separate.
|
||||
wheres = []string{}
|
||||
placeholders = []interface{}{}
|
||||
comment_wheres = []string{"table_name = 'threads'"}
|
||||
comment_ph = []interface{}{}
|
||||
)
|
||||
|
||||
if len(categories) > 0 {
|
||||
|
@ -44,14 +52,19 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
|
|||
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 ?")
|
||||
placeholders = append(placeholders, blockedUserIDs)
|
||||
comment_wheres = append(comment_wheres, "comments.user_id NOT IN ?")
|
||||
comment_ph = append(comment_ph, blockedUserIDs)
|
||||
}
|
||||
|
||||
// Don't show comments from banned or disabled accounts.
|
||||
wheres = append(wheres, `
|
||||
comment_wheres = append(comment_wheres, `
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM users
|
||||
|
@ -61,30 +74,25 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
|
|||
`)
|
||||
|
||||
// Get the page of recent forum comment IDs of all time.
|
||||
type scanner struct {
|
||||
CommentID uint64
|
||||
ThreadID *uint64
|
||||
ForumID *uint64
|
||||
}
|
||||
var scan []scanner
|
||||
query = DB.Table("comments").Select(
|
||||
`comments.id AS comment_id,
|
||||
threads.id AS thread_id,
|
||||
forums.id AS forum_id`,
|
||||
).Joins(
|
||||
"LEFT OUTER JOIN threads ON (table_name = 'threads' AND table_id = threads.id)",
|
||||
).Joins(
|
||||
"LEFT OUTER JOIN forums ON (threads.forum_id = forums.id)",
|
||||
).Where(
|
||||
strings.Join(wheres, " AND "),
|
||||
placeholders...,
|
||||
).Order("comments.updated_at desc")
|
||||
var scan NewestForumPostsScanner
|
||||
|
||||
// Get the total for the pager and scan the page of ID sets.
|
||||
query.Model(&Comment{}).Count(&pager.Total)
|
||||
query = query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&scan)
|
||||
if query.Error != nil {
|
||||
return nil, query.Error
|
||||
// Deduplicate forum threads: if one thread is BLOWING UP with replies, we should only
|
||||
// mention the thread once and show the newest comment so it doesn't spam the whole page.
|
||||
if config.Current.Database.IsPostgres && !allComments {
|
||||
// Note: only Postgres supports this function (SELECT DISTINCT ON).
|
||||
if res, err := ScanLatestForumCommentsPerThread(wheres, comment_wheres, placeholders, comment_ph, pager); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
scan = res
|
||||
}
|
||||
} else {
|
||||
// SQLite/non-Postgres doesn't support DISTINCT ON, this is the old query which
|
||||
// shows objectively all comments and a popular thread may dominate the page.
|
||||
if res, err := ScanLatestForumCommentsAll(wheres, comment_wheres, placeholders, comment_ph, pager); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
scan = res
|
||||
}
|
||||
}
|
||||
|
||||
// Ingest the results.
|
||||
|
@ -181,6 +189,13 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
|
|||
}
|
||||
}
|
||||
|
||||
// Is the new comment unavailable? (e.g. blocked, banned, disabled)
|
||||
if rc.Comment == nil {
|
||||
rc.Comment = &Comment{
|
||||
Message: "[unavailable]",
|
||||
}
|
||||
}
|
||||
|
||||
if f, ok := forums[rc.ForumID]; ok {
|
||||
rc.Forum = f
|
||||
}
|
||||
|
@ -192,3 +207,140 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
|
|||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// NewestForumPosts collects the IDs of the latest forum posts.
|
||||
type NewestForumPosts struct {
|
||||
CommentID uint64
|
||||
ThreadID *uint64
|
||||
ForumID *uint64
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type NewestForumPostsScanner []NewestForumPosts
|
||||
|
||||
// ScanLatestForumCommentsAll returns a scan of Newest forum posts containing ALL comments, which may
|
||||
// include runs of 'duplicate' forum threads if a given thread was commented on rapidly. This is the classic
|
||||
// 'Newest' tab behavior, showing just ALL forum comments by newest.
|
||||
func ScanLatestForumCommentsAll(wheres, comment_wheres []string, placeholders, comment_ph []interface{}, pager *Pagination) (NewestForumPostsScanner, error) {
|
||||
var scan NewestForumPostsScanner
|
||||
|
||||
// This one is all one joined query so join the wheres/placeholders.
|
||||
wheres = append(wheres, comment_wheres...)
|
||||
placeholders = append(placeholders, comment_ph...)
|
||||
|
||||
// SQLite/non-Postgres doesn't support DISTINCT ON, this is the old query which
|
||||
// shows objectively all comments and a popular thread may dominate the page.
|
||||
query := DB.Table("comments").Select(
|
||||
`comments.id AS comment_id,
|
||||
threads.id AS thread_id,
|
||||
forums.id AS forum_id,
|
||||
comments.updated_at AS updated_at`,
|
||||
).Joins(
|
||||
"LEFT OUTER JOIN threads ON (table_name = 'threads' AND table_id = threads.id)",
|
||||
).Joins(
|
||||
"LEFT OUTER JOIN forums ON (threads.forum_id = forums.id)",
|
||||
).Where(
|
||||
strings.Join(wheres, " AND "),
|
||||
placeholders...,
|
||||
).Order("comments.updated_at desc")
|
||||
query.Model(&Comment{}).Count(&pager.Total)
|
||||
|
||||
// Execute the query.
|
||||
query = query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&scan)
|
||||
return scan, query.Error
|
||||
}
|
||||
|
||||
// ScanLatestForumCommentsPerThread returns a scan of Newest forum posts, deduplicated by thread.
|
||||
// Each thread ID will only appear once in the result, paired with the newest comment in that
|
||||
// thread.
|
||||
func ScanLatestForumCommentsPerThread(wheres, comment_wheres []string, placeholders, comment_ph []interface{}, pager *Pagination) (NewestForumPostsScanner, error) {
|
||||
var (
|
||||
result NewestForumPostsScanner
|
||||
threadIDs = []uint64{}
|
||||
|
||||
// Query for ALL thread IDs (in forums the user can see).
|
||||
query = DB.Table(
|
||||
"threads",
|
||||
).Select(`
|
||||
DISTINCT ON (threads.id)
|
||||
threads.forum_id,
|
||||
threads.id AS thread_id,
|
||||
threads.updated_at AS updated_at
|
||||
`).Joins(
|
||||
"JOIN forums ON (threads.forum_id = forums.id)",
|
||||
).Where(
|
||||
strings.Join(wheres, " AND "),
|
||||
placeholders...,
|
||||
).Order(
|
||||
"threads.id",
|
||||
)
|
||||
)
|
||||
|
||||
query = query.Find(&result)
|
||||
if query.Error != nil {
|
||||
return result, query.Error
|
||||
}
|
||||
pager.Total = int64(len(result))
|
||||
|
||||
// Reorder the result by timestamp.
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].UpdatedAt.After(result[j].UpdatedAt)
|
||||
})
|
||||
|
||||
// Subslice the result per the user's pagination setting.
|
||||
var (
|
||||
start = pager.GetOffset()
|
||||
stop = start + pager.PerPage
|
||||
)
|
||||
if start > len(result) {
|
||||
return NewestForumPostsScanner{}, nil
|
||||
} else if stop > len(result) {
|
||||
stop = len(result)
|
||||
}
|
||||
result = result[start:stop]
|
||||
|
||||
// Map the thread IDs to their result row.
|
||||
var threadMap = map[uint64]int{}
|
||||
for i, row := range result {
|
||||
threadIDs = append(threadIDs, *row.ThreadID)
|
||||
threadMap[*row.ThreadID] = i
|
||||
}
|
||||
|
||||
// With these thread IDs, select the newest comments.
|
||||
type scanner struct {
|
||||
ThreadID uint64
|
||||
CommentID uint64
|
||||
}
|
||||
var scan []scanner
|
||||
err := DB.Table(
|
||||
"comments",
|
||||
).Select(
|
||||
"table_id AS thread_id, id AS comment_id",
|
||||
).Where(
|
||||
`table_name='threads' AND table_id IN ?
|
||||
AND updated_at = (SELECT MAX(updated_at)
|
||||
FROM comments c2
|
||||
WHERE c2.table_name=comments.table_name
|
||||
AND c2.table_id=comments.table_id
|
||||
)`,
|
||||
threadIDs,
|
||||
).Where(
|
||||
strings.Join(comment_wheres, " AND "),
|
||||
comment_ph...,
|
||||
).Order(
|
||||
"updated_at desc",
|
||||
).Scan(&scan)
|
||||
if err.Error != nil {
|
||||
log.Error("Getting most recent post IDs: %s", err.Error)
|
||||
return result, err.Error
|
||||
}
|
||||
|
||||
// Populate the comment IDs back in.
|
||||
for _, row := range scan {
|
||||
if idx, ok := threadMap[row.ThreadID]; ok {
|
||||
result[idx].CommentID = row.CommentID
|
||||
}
|
||||
}
|
||||
|
||||
return result, query.Error
|
||||
}
|
||||
|
|
|
@ -100,6 +100,11 @@ 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 ?")
|
||||
|
|
|
@ -230,7 +230,6 @@ func (ts ForumStatsMap) generateRecentPosts(IDs []uint64) {
|
|||
"comments",
|
||||
).Select(
|
||||
"table_id AS thread_id, id AS comment_id",
|
||||
// "forum_id, id AS thread_id, updated_at",
|
||||
).Where(
|
||||
`table_name='threads' AND table_id IN ?
|
||||
AND updated_at = (SELECT MAX(updated_at)
|
||||
|
|
|
@ -6,7 +6,6 @@ import (
|
|||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/log"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Friend table.
|
||||
|
@ -17,7 +16,7 @@ type Friend struct {
|
|||
Approved bool `gorm:"index"`
|
||||
Ignored bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
UpdatedAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
// AddFriend sends a friend request or accepts one if there was already a pending one.
|
||||
|
@ -249,11 +248,19 @@ func FriendIDsInCircleAreExplicit(userId uint64) []uint64 {
|
|||
|
||||
// CountFriendRequests gets a count of pending requests for the user.
|
||||
func CountFriendRequests(userID uint64) (int64, error) {
|
||||
var count int64
|
||||
var (
|
||||
count int64
|
||||
wheres = []string{
|
||||
"target_user_id = ? AND approved = ? AND ignored IS NOT true",
|
||||
"EXISTS (SELECT 1 FROM users WHERE users.id = source_user_id AND users.status = 'active')",
|
||||
}
|
||||
placeholders = []interface{}{
|
||||
userID, false,
|
||||
}
|
||||
)
|
||||
result := DB.Where(
|
||||
"target_user_id = ? AND approved = ? AND ignored IS NOT true",
|
||||
userID,
|
||||
false,
|
||||
strings.Join(wheres, " AND "),
|
||||
placeholders...,
|
||||
).Model(&Friend{}).Count(&count)
|
||||
return count, result.Error
|
||||
}
|
||||
|
@ -262,7 +269,7 @@ func CountFriendRequests(userID uint64) (int64, error) {
|
|||
func CountIgnoredFriendRequests(userID uint64) (int64, error) {
|
||||
var count int64
|
||||
result := DB.Where(
|
||||
"target_user_id = ? AND approved = ? AND ignored = ?",
|
||||
"target_user_id = ? AND approved = ? AND ignored = ? AND EXISTS (SELECT 1 FROM users WHERE users.id = friends.source_user_id AND users.status = 'active')",
|
||||
userID,
|
||||
false,
|
||||
true,
|
||||
|
@ -295,38 +302,77 @@ have sent and have not been answered.
|
|||
func PaginateFriends(user *User, requests bool, sent bool, ignored bool, pager *Pagination) ([]*User, error) {
|
||||
// We paginate over the Friend table.
|
||||
var (
|
||||
fs = []*Friend{}
|
||||
userIDs = []uint64{}
|
||||
query *gorm.DB
|
||||
fs = []*Friend{}
|
||||
userIDs = []uint64{}
|
||||
blockedUserIDs = BlockedUserIDs(user)
|
||||
wheres = []string{}
|
||||
placeholders = []interface{}{}
|
||||
query = DB.Model(&Friend{})
|
||||
)
|
||||
|
||||
if requests && sent && ignored {
|
||||
return nil, errors.New("requests and sent are mutually exclusive options, use one or neither")
|
||||
}
|
||||
|
||||
if requests {
|
||||
query = DB.Where(
|
||||
"target_user_id = ? AND approved = ? AND ignored IS NOT true",
|
||||
user.ID, false,
|
||||
)
|
||||
} else if sent {
|
||||
query = DB.Where(
|
||||
"source_user_id = ? AND approved = ? AND ignored IS NOT true",
|
||||
user.ID, false,
|
||||
)
|
||||
} else if ignored {
|
||||
query = DB.Where(
|
||||
"target_user_id = ? AND approved = ? AND ignored = ?",
|
||||
user.ID, false, true,
|
||||
)
|
||||
} else {
|
||||
query = DB.Where(
|
||||
"source_user_id = ? AND approved = ?",
|
||||
user.ID, true,
|
||||
)
|
||||
// Don't show our blocked users in the result.
|
||||
if len(blockedUserIDs) > 0 {
|
||||
wheres = append(wheres, "target_user_id NOT IN ?")
|
||||
placeholders = append(placeholders, blockedUserIDs)
|
||||
}
|
||||
|
||||
query = query.Order(pager.Sort)
|
||||
// Don't show disabled or banned users.
|
||||
var (
|
||||
// Source user is banned (Requests, Ignored tabs)
|
||||
bannedWhereRequest = `
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM users
|
||||
WHERE users.id = friends.source_user_id
|
||||
AND users.status = 'active'
|
||||
)
|
||||
`
|
||||
|
||||
// Target user is banned (Friends, Sent tabs)
|
||||
bannedWhereFriend = `
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM users
|
||||
WHERE users.id = friends.target_user_id
|
||||
AND users.status = 'active'
|
||||
)
|
||||
`
|
||||
)
|
||||
|
||||
if requests {
|
||||
wheres = append(wheres, "target_user_id = ? AND approved = ? AND ignored IS NOT true")
|
||||
placeholders = append(placeholders, user.ID, false)
|
||||
|
||||
// Don't show friend requests from currently banned/disabled users.
|
||||
wheres = append(wheres, bannedWhereRequest)
|
||||
} else if sent {
|
||||
wheres = append(wheres, "source_user_id = ? AND approved = ? AND ignored IS NOT true")
|
||||
placeholders = append(placeholders, user.ID, false)
|
||||
|
||||
// Don't show friends who are currently banned/disabled.
|
||||
wheres = append(wheres, bannedWhereFriend)
|
||||
} else if ignored {
|
||||
wheres = append(wheres, "target_user_id = ? AND approved = ? AND ignored = ?")
|
||||
placeholders = append(placeholders, user.ID, false, true)
|
||||
|
||||
// Don't show friend requests from currently banned/disabled users.
|
||||
wheres = append(wheres, bannedWhereRequest)
|
||||
} else {
|
||||
wheres = append(wheres, "source_user_id = ? AND approved = ?")
|
||||
placeholders = append(placeholders, user.ID, true)
|
||||
|
||||
// Don't show friends who are currently banned/disabled.
|
||||
wheres = append(wheres, bannedWhereFriend)
|
||||
}
|
||||
|
||||
query = query.Where(
|
||||
strings.Join(wheres, " AND "),
|
||||
placeholders...,
|
||||
).Order(pager.Sort)
|
||||
query.Model(&Friend{}).Count(&pager.Total)
|
||||
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
|
||||
if result.Error != nil {
|
||||
|
@ -446,6 +492,27 @@ func RemoveFriend(sourceUserID, targetUserID uint64) error {
|
|||
return result.Error
|
||||
}
|
||||
|
||||
// RevokeFriendPhotoNotifications removes notifications about newly uploaded friends photos
|
||||
// that were sent to your former friends, when you remove their friendship.
|
||||
//
|
||||
// For example: if I unfriend you, all your past notifications that showed my friends-only photos should
|
||||
// be revoked so that you can't see them anymore.
|
||||
//
|
||||
// Notifications about friend photos are revoked going in both directions.
|
||||
func RevokeFriendPhotoNotifications(currentUser, other *User) error {
|
||||
// Gather the IDs of all their friends-only photos to nuke notifications for.
|
||||
allPhotoIDs, err := AllFriendsOnlyPhotoIDs(currentUser, other)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if len(allPhotoIDs) == 0 {
|
||||
// Nothing to do.
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info("RevokeFriendPhotoNotifications(%s): forget about friend photo uploads for user %s on photo IDs: %v", currentUser.Username, other.Username, allPhotoIDs)
|
||||
return RemoveSpecificNotificationBulk([]*User{currentUser, other}, NotificationNewPhoto, "photos", allPhotoIDs)
|
||||
}
|
||||
|
||||
// Save photo.
|
||||
func (f *Friend) Save() error {
|
||||
result := DB.Save(f)
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
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
|
||||
}
|
|
@ -9,11 +9,11 @@ import (
|
|||
|
||||
// Like table.
|
||||
type Like struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
UserID uint64 `gorm:"index"` // who it belongs to
|
||||
TableName string
|
||||
TableID uint64
|
||||
CreatedAt time.Time
|
||||
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"`
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,8 @@ package models
|
|||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.nonshy.com/nonshy/website/pkg/config"
|
||||
)
|
||||
|
||||
// Message table.
|
||||
|
@ -227,6 +229,19 @@ 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)
|
||||
|
|
|
@ -31,4 +31,6 @@ func AutoMigrate() {
|
|||
DB.AutoMigrate(&UserLocation{})
|
||||
DB.AutoMigrate(&UserNote{})
|
||||
DB.AutoMigrate(&TwoFactor{})
|
||||
DB.AutoMigrate(&ChangeLog{})
|
||||
DB.AutoMigrate(&IPAddress{})
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
@ -40,13 +42,13 @@ const (
|
|||
NotificationAlsoPosted NotificationType = "also_posted" // forum replies
|
||||
NotificationCertRejected NotificationType = "cert_rejected"
|
||||
NotificationCertApproved NotificationType = "cert_approved"
|
||||
NotificationPrivatePhoto NotificationType = "private_photo"
|
||||
NotificationPrivatePhoto NotificationType = "private_photo" // private photo grants
|
||||
NotificationNewPhoto NotificationType = "new_photo"
|
||||
NotificationInnerCircle NotificationType = "inner_circle"
|
||||
NotificationCustom NotificationType = "custom" // custom message pushed
|
||||
)
|
||||
|
||||
// CreateNotification
|
||||
// CreateNotification inserts a new notification into the database.
|
||||
func CreateNotification(n *Notification) error {
|
||||
// Insert via raw SQL query, reasoning:
|
||||
// the AboutUser relationship has gorm do way too much work:
|
||||
|
@ -99,6 +101,33 @@ 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 {
|
||||
|
@ -119,12 +148,32 @@ 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(userID uint64, t NotificationType, tableName string, tableIDs []uint64) error {
|
||||
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")
|
||||
}
|
||||
|
||||
result := DB.Where(
|
||||
"user_id = ? AND type = ? AND table_name = ? AND table_id IN ?",
|
||||
userID, t, tableName, tableIDs,
|
||||
"user_id IN ? AND type = ? AND table_name = ? AND table_id IN ?",
|
||||
userIDs, t, tableName, tableIDs,
|
||||
).Delete(&Notification{})
|
||||
return result.Error
|
||||
}
|
||||
|
@ -183,7 +232,7 @@ func CountUnreadNotifications(user *User) (int64, error) {
|
|||
}
|
||||
|
||||
// PaginateNotifications returns the user's notifications.
|
||||
func PaginateNotifications(user *User, pager *Pagination) ([]*Notification, error) {
|
||||
func PaginateNotifications(user *User, filters NotificationFilter, pager *Pagination) ([]*Notification, error) {
|
||||
var (
|
||||
ns = []*Notification{}
|
||||
blockedUserIDs = BlockedUserIDs(user)
|
||||
|
@ -211,6 +260,12 @@ func PaginateNotifications(user *User, pager *Pagination) ([]*Notification, erro
|
|||
)
|
||||
`)
|
||||
|
||||
// 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...,
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
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
|
||||
}
|
|
@ -19,11 +19,12 @@ type Photo struct {
|
|||
CroppedFilename string // if cropped, e.g. for profile photo
|
||||
Filesize int64
|
||||
Caption string
|
||||
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
|
||||
CreatedAt time.Time
|
||||
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"`
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
|
@ -77,6 +78,7 @@ 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,
|
||||
|
@ -212,6 +214,34 @@ 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.
|
||||
|
@ -444,6 +474,7 @@ 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
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -547,6 +578,20 @@ 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 ?")
|
||||
|
|
|
@ -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 *User, fromUserID *uint64) error {
|
||||
func RevokePrivatePhotoNotifications(currentUser, fromUser *User) 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 *User, fromUserID *uint64) erro
|
|||
}
|
||||
|
||||
// Who to clear the notifications for?
|
||||
if fromUserID == nil {
|
||||
if fromUser == 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 %d on photo IDs: %v", currentUser.Username, *fromUserID, photoIDs)
|
||||
return RemoveSpecificNotificationBulk(*fromUserID, NotificationNewPhoto, "photos", photoIDs)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -184,6 +184,11 @@ 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)
|
||||
|
|
|
@ -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
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
Username string `gorm:"uniqueIndex"`
|
||||
Email string `gorm:"uniqueIndex"`
|
||||
HashedPassword string `json:"-"`
|
||||
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
|
||||
ProfileField []ProfileField `json:"-"`
|
||||
ProfilePhotoID *uint64
|
||||
ProfilePhoto Photo `gorm:"foreignKey:profile_photo_id"`
|
||||
AdminGroups []*AdminGroup `gorm:"many2many:admin_group_users;"`
|
||||
AdminGroups []*AdminGroup `gorm:"many2many:admin_group_users;" json:"-"`
|
||||
|
||||
// Current user's relationship to this user -- not stored in DB.
|
||||
UserRelationship UserRelationship `gorm:"-"`
|
||||
|
@ -186,6 +186,28 @@ 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.
|
||||
|
@ -204,6 +226,31 @@ 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
|
||||
|
@ -216,6 +263,8 @@ type UserSearch struct {
|
|||
InnerCircle bool
|
||||
ShyAccounts bool
|
||||
IsBanned bool
|
||||
IsDisabled bool
|
||||
IsAdmin bool // search for admin users
|
||||
Friends bool
|
||||
AgeMin int
|
||||
AgeMax int
|
||||
|
@ -245,7 +294,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 order by distance because your location is not known")
|
||||
return users, errors.New("can not sort members by distance because your location is not known")
|
||||
}
|
||||
|
||||
// Only query for users who have locations.
|
||||
|
@ -315,14 +364,22 @@ func SearchUsers(user *User, search *UserSearch, pager *Pagination) ([]*User, er
|
|||
placeholders = append(placeholders, "here_for", "%"+search.HereFor+"%")
|
||||
}
|
||||
|
||||
// All user searches will show active accounts only, unless we are admin.
|
||||
if user.IsAdmin && search.IsBanned {
|
||||
// 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 {
|
||||
wheres = append(wheres, "status IN ?")
|
||||
placeholders = append(placeholders, []string{
|
||||
UserStatusBanned,
|
||||
UserStatusDisabled,
|
||||
})
|
||||
} else if !user.IsAdmin {
|
||||
placeholders = append(placeholders, statuses)
|
||||
} else {
|
||||
wheres = append(wheres, "status = ?")
|
||||
placeholders = append(placeholders, UserStatusActive)
|
||||
}
|
||||
|
@ -339,6 +396,10 @@ 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)
|
||||
|
|
|
@ -57,7 +57,7 @@ func RemoveFromInnerCircle(u *User) error {
|
|||
if err := DB.Model(&Photo{}).Where(
|
||||
"user_id = ? AND visibility = ?",
|
||||
u.ID, PhotoInnerCircle,
|
||||
).Update("visibility", PhotoFriends); err != nil {
|
||||
).Update("visibility", PhotoPrivate); err != nil {
|
||||
log.Error("RemoveFromInnerCircle: couldn't update photo visibility: %s", err.Error)
|
||||
}
|
||||
|
||||
|
|
|
@ -14,11 +14,13 @@ 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 {
|
||||
|
@ -26,21 +28,21 @@ func New() http.Handler {
|
|||
|
||||
// Register controller endpoints.
|
||||
mux.HandleFunc("/", index.Create())
|
||||
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("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("/contact", index.Contact())
|
||||
mux.HandleFunc("/login", account.Login())
|
||||
mux.HandleFunc("/logout", account.Logout())
|
||||
mux.HandleFunc("GET /logout", account.Logout())
|
||||
mux.Handle("/signup", middleware.GeoGate(account.Signup()))
|
||||
mux.HandleFunc("/forgot-password", account.ForgotPassword())
|
||||
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")())
|
||||
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")())
|
||||
|
||||
// Login Required. Pages that non-certified users can access.
|
||||
mux.Handle("/me", middleware.LoginRequired(account.Dashboard()))
|
||||
|
@ -49,75 +51,86 @@ 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("/account/reactivate", middleware.LoginRequired(account.Reactivate()))
|
||||
mux.Handle("/u/", account.Profile()) // public access OK
|
||||
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("/photo/upload", middleware.LoginRequired(photo.Upload()))
|
||||
mux.Handle("/photo/u/", middleware.LoginRequired(photo.UserPhotos()))
|
||||
mux.Handle("/photo/view", middleware.LoginRequired(photo.View()))
|
||||
mux.Handle("GET /photo/view", middleware.LoginRequired(photo.View()))
|
||||
mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit()))
|
||||
mux.Handle("/photo/delete", middleware.LoginRequired(photo.Delete()))
|
||||
mux.Handle("/photo/certification", middleware.LoginRequired(photo.Certification()))
|
||||
mux.Handle("/photo/private", middleware.LoginRequired(photo.Private()))
|
||||
mux.Handle("GET /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("/messages", middleware.LoginRequired(inbox.Inbox()))
|
||||
mux.Handle("/messages/read/", middleware.LoginRequired(inbox.Inbox()))
|
||||
mux.Handle("GET /messages", middleware.LoginRequired(inbox.Inbox()))
|
||||
mux.Handle("GET /messages/read/{id}", middleware.LoginRequired(inbox.Inbox()))
|
||||
mux.Handle("/messages/compose", middleware.LoginRequired(inbox.Compose()))
|
||||
mux.Handle("/messages/delete", middleware.LoginRequired(inbox.Delete()))
|
||||
mux.Handle("/friends", middleware.LoginRequired(friend.Friends()))
|
||||
mux.Handle("GET /friends", middleware.LoginRequired(friend.Friends()))
|
||||
mux.Handle("/friends/add", middleware.LoginRequired(friend.AddFriend()))
|
||||
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("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("/comments", middleware.LoginRequired(comment.PostComment()))
|
||||
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("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("/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("/photo/gallery", middleware.CertRequired(photo.SiteGallery()))
|
||||
mux.Handle("/members", middleware.CertRequired(account.Search()))
|
||||
mux.Handle("GET /photo/gallery", middleware.CertRequired(photo.SiteGallery()))
|
||||
mux.Handle("GET /members", middleware.CertRequired(account.Search()))
|
||||
mux.Handle("/chat", middleware.CertRequired(chat.Landing()))
|
||||
mux.Handle("/forum", middleware.CertRequired(forum.Landing()))
|
||||
mux.Handle("GET /forum", middleware.CertRequired(forum.Landing()))
|
||||
mux.Handle("/forum/post", middleware.CertRequired(forum.NewPost()))
|
||||
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()))
|
||||
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()))
|
||||
|
||||
// Admin endpoints.
|
||||
mux.Handle("/admin", middleware.AdminRequired("", admin.Dashboard()))
|
||||
mux.Handle("GET /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("", admin.Feedback()))
|
||||
mux.Handle("/admin/feedback", middleware.AdminRequired(config.ScopeFeedbackAndReports, 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("/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())
|
||||
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()))
|
||||
|
||||
// Redirect endpoints.
|
||||
mux.Handle("/go/comment", middleware.LoginRequired(comment.GoToComment()))
|
||||
mux.Handle("GET /go/comment", middleware.LoginRequired(comment.GoToComment()))
|
||||
|
||||
// Static files.
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(config.StaticPath))))
|
||||
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"))
|
||||
|
||||
// Global middlewares.
|
||||
withCSRF := middleware.CSRF(mux)
|
||||
|
|
|
@ -32,6 +32,7 @@ const (
|
|||
ContextKey = "session"
|
||||
CurrentUserKey = "current_user"
|
||||
CSRFKey = "csrf"
|
||||
RequestTimeKey = "req_time"
|
||||
)
|
||||
|
||||
// New creates a blank session object.
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
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
|
||||
}
|
|
@ -1,9 +1,37 @@
|
|||
package templates
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"fmt"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,6 +40,7 @@ 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>` +
|
||||
|
@ -70,6 +71,9 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -89,6 +93,15 @@ 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 {
|
||||
|
|
|
@ -18,6 +18,7 @@ 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
|
||||
|
@ -55,6 +56,9 @@ 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,
|
||||
|
@ -100,11 +104,20 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
|
|||
log.Error("MergeUserVars: couldn't CountFriendRequests for %d: %s", user.ID, err)
|
||||
}
|
||||
|
||||
// Are we admin?
|
||||
// Are we admin? Add notification counts if the current admin can respond to them.
|
||||
if user.IsAdmin {
|
||||
var countCertPhotos, countFeedback int64
|
||||
|
||||
// Any pending certification photos or feedback?
|
||||
countCertPhotos = models.CountCertificationPhotosNeedingApproval()
|
||||
countFeedback = models.CountUnreadFeedback()
|
||||
if user.HasAdminScope(config.ScopeCertificationApprove) {
|
||||
countCertPhotos = models.CountCertificationPhotosNeedingApproval()
|
||||
}
|
||||
|
||||
// Admin feedback available?
|
||||
if user.HasAdminScope(config.ScopeFeedbackAndReports) {
|
||||
countFeedback = models.CountUnreadFeedback()
|
||||
}
|
||||
|
||||
m["NavCertificationPhotos"] = countCertPhotos
|
||||
m["NavAdminFeedback"] = countFeedback
|
||||
|
||||
|
|
|
@ -46,6 +46,28 @@ 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)
|
||||
|
@ -55,6 +77,15 @@ 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 {
|
||||
|
@ -134,6 +165,7 @@ 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",
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ package worker
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"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()
|
||||
|
||||
if cachedChatStatistics != nil {
|
||||
return *cachedChatStatistics
|
||||
}
|
||||
return ChatStatistics{
|
||||
Usernames: []string{},
|
||||
}
|
||||
return cachedChatStatistics
|
||||
}
|
||||
|
||||
// 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,10 +51,20 @@ 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
|
||||
|
@ -68,7 +78,7 @@ func (m UserOnChatMap) Get(username string) bool {
|
|||
}
|
||||
|
||||
var (
|
||||
cachedChatStatistics *ChatStatistics
|
||||
cachedChatStatistics = &ChatStatistics{}
|
||||
chatStatisticsMu sync.RWMutex
|
||||
)
|
||||
|
||||
|
@ -117,7 +127,7 @@ func DoCheckBareRTC() {
|
|||
|
||||
if res.StatusCode == http.StatusOK {
|
||||
var cs ChatStatistics
|
||||
body, _ := ioutil.ReadAll(res.Body)
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err = json.Unmarshal(body, &cs); err != nil {
|
||||
log.Error("WatchBareRTC: json decode error: %s", err)
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
/* 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
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
|
@ -0,0 +1,67 @@
|
|||
/* 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;
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
/* Custom nonshy color overrides for Bulma's dark theme
|
||||
(prefers-dark edition) */
|
||||
@import url("dark-theme.css") screen and (prefers-color-scheme: dark);
|
|
@ -12,6 +12,10 @@ 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;
|
||||
|
@ -46,9 +50,26 @@ img {
|
|||
|
||||
/* Photo modals in addition to Bulma .modal-content */
|
||||
.photo-modal {
|
||||
width: auto !important;
|
||||
max-width: fit-content;
|
||||
max-height: fit-content;
|
||||
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;
|
||||
}
|
||||
|
||||
/* Custom bulma tag colors */
|
||||
|
@ -96,6 +117,11 @@ 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;
|
||||
|
@ -170,4 +196,12 @@ 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.
After Width: | Height: | Size: 5.6 KiB |
Binary file not shown.
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
After Width: | Height: | Size: 206 KiB |
Binary file not shown.
After Width: | Height: | Size: 107 KiB |
Binary file not shown.
After Width: | Height: | Size: 216 KiB |
Binary file not shown.
After Width: | Height: | Size: 6.1 KiB |
|
@ -97,14 +97,15 @@ 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");
|
||||
icon = header.querySelector("button.card-header-icon > .icon > i"),
|
||||
always = node.classList.contains("nonshy-collapsible-always");
|
||||
|
||||
// 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) {
|
||||
if (screen.width <= 768 || always) {
|
||||
body.style.display = "none";
|
||||
if (icon !== null) {
|
||||
icon.classList.remove(iconExpanded);
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,42 @@
|
|||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,7 +5,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
cls = 'is-active';
|
||||
|
||||
// Disable context menu on all images.
|
||||
(document.querySelectorAll('img, video') || []).forEach(node => {
|
||||
(document.querySelectorAll('img, video, #detailImg') || []).forEach(node => {
|
||||
node.addEventListener('contextmenu', (e) => {
|
||||
$modal.classList.add(cls);
|
||||
e.preventDefault();
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"short_name": "nonshy",
|
||||
"name": "A social network for nudists and exhibitionists.",
|
||||
"name": "nonshy",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/img/favicon.svg",
|
||||
|
|
|
@ -94,6 +94,7 @@
|
|||
window.alert(`NOTE: Your input was interpreted to be in MM/DD/YYYY order and has been read as: ${answer}`);
|
||||
} else if (!answer.match(/^\d{4}-\d{2}-\d{2}/)) {
|
||||
window.alert(`Please enter the date in YYYY-MM-DD format.`);
|
||||
return;
|
||||
}
|
||||
|
||||
$dob.value = answer;
|
||||
|
|
|
@ -34,33 +34,45 @@
|
|||
<ul class="menu-list block">
|
||||
<li>
|
||||
<a href="/photo/upload?intent=profile_pic">
|
||||
{{if .CurrentUser.ProfilePhoto.ID}}
|
||||
<span class="icon"><i class="fa fa-circle-check has-text-success"></i></span>
|
||||
{{else}}
|
||||
<span class="icon"><i class="fa fa-circle has-text-danger"></i></span>
|
||||
{{end}}
|
||||
<span>
|
||||
Add a Profile Picture
|
||||
{{if not .CurrentUser.ProfilePhoto.ID}}
|
||||
<span class="icon"><i class="fa fa-external-link"></i></span>
|
||||
{{end}}
|
||||
</span>
|
||||
<div class="columns is-mobile is-gapless">
|
||||
<div class="column is-narrow mr-2">
|
||||
{{if .CurrentUser.ProfilePhoto.ID}}
|
||||
<span class="icon"><i class="fa fa-circle-check has-text-success"></i></span>
|
||||
{{else}}
|
||||
<span class="icon"><i class="fa fa-circle-xmark has-text-danger"></i></span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="column">
|
||||
Upload a Profile Picture to your account that shows your face
|
||||
|
||||
<p class="help">
|
||||
Click here to upload a new profile picture
|
||||
<i class="fa fa-external-link ml-1"></i>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="/photo/certification">
|
||||
{{if .CurrentUser.Certified}}
|
||||
<span class="icon"><i class="fa fa-circle-check has-text-success"></i></span>
|
||||
{{else}}
|
||||
<span class="icon"><i class="fa fa-circle has-text-danger"></i></span>
|
||||
{{end}}
|
||||
<span>
|
||||
Get certified by uploading a verification selfie
|
||||
{{if not .CurrentUser.Certified}}
|
||||
<span class="icon"><i class="fa fa-external-link"></i></span>
|
||||
{{end}}
|
||||
</span>
|
||||
<div class="columns is-mobile is-gapless">
|
||||
<div class="column is-narrow mr-2">
|
||||
{{if .CurrentUser.Certified}}
|
||||
<span class="icon"><i class="fa fa-circle-check has-text-success"></i></span>
|
||||
{{else}}
|
||||
<span class="icon"><i class="fa fa-circle-xmark has-text-danger"></i></span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="column">
|
||||
Get certified by uploading a verification selfie
|
||||
|
||||
<p class="help">
|
||||
Click here to go to the Certification Photo upload page
|
||||
<i class="fa fa-external-link ml-1"></i>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
@ -114,7 +126,7 @@
|
|||
</li>
|
||||
|
||||
<li>
|
||||
<a href="/photo/u/{{.CurrentUser.Username}}">
|
||||
<a href="/u/{{.CurrentUser.Username}}/photos">
|
||||
{{if .HasPublicPhoto}}
|
||||
<span class="icon"><i class="fa fa-circle-check has-text-success"></i></span>
|
||||
{{else}}
|
||||
|
@ -136,7 +148,7 @@
|
|||
<!-- New Feature -->
|
||||
{{if not (.CurrentUser.GetProfileField "hero-color-start")}}
|
||||
<div class="card block">
|
||||
<header class="card-header has-background-success-dark">
|
||||
<header class="card-header has-background-success">
|
||||
<p class="card-header-title has-text-light">
|
||||
<i class="fa fa-gift mr-2"></i>
|
||||
New Feature: Profile Look & Feel
|
||||
|
@ -170,84 +182,86 @@
|
|||
</header>
|
||||
|
||||
<div class="card-content">
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
<a href="/u/{{.CurrentUser.Username}}">
|
||||
<span class="icon"><i class="fa fa-user"></i></span>
|
||||
My Profile
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/photo/u/{{.CurrentUser.Username}}">
|
||||
<span class="icon"><i class="fa fa-image"></i></span>
|
||||
My Photos
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/photo/upload">
|
||||
<span class="icon"><i class="fa fa-upload"></i></span>
|
||||
Upload Photos
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/photo/private">
|
||||
<span class="icon"><i class="fa fa-eye"></i></span>
|
||||
Manage Private Photos
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/settings">
|
||||
<span class="icon"><i class="fa fa-edit"></i></span>
|
||||
Edit Profile & Settings
|
||||
</a>
|
||||
</li>
|
||||
{{if .CurrentUser.IsInnerCircle}}
|
||||
<li>
|
||||
<a href="/inner-circle">
|
||||
<span class="icon"><img src="/static/img/circle-16.png"></span>
|
||||
{{PrettyCircle}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
<li>
|
||||
<a href="/photo/certification">
|
||||
<span class="icon"><i class="fa fa-certificate"></i></span>
|
||||
Certification Photo
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/users/blocked">
|
||||
<span class="icon"><i class="fa fa-hand"></i></span>
|
||||
Blocked Users
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/notes/me">
|
||||
<span class="icon"><i class="fa fa-pen-to-square mr-1"></i></span>
|
||||
My User Notes
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/logout">
|
||||
<span class="icon"><i class="fa fa-arrow-right-from-bracket"></i></span>
|
||||
Log out
|
||||
</a>
|
||||
</li>
|
||||
{{if .SessionImpersonated}}
|
||||
<li>
|
||||
<a href="/admin/unimpersonate" class="has-text-danger">
|
||||
<span class="icon"><i class="fa fa-ghost"></i></span>
|
||||
<span>Unimpersonate</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
<li>
|
||||
<a href="/settings#deactivate">
|
||||
<span class="icon"><i class="fa fa-trash"></i></span>
|
||||
Delete account
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<aside class="menu">
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
<a href="/u/{{.CurrentUser.Username}}">
|
||||
<span class="icon"><i class="fa fa-user"></i></span>
|
||||
My Profile
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/u/{{.CurrentUser.Username}}/photos">
|
||||
<span class="icon"><i class="fa fa-image"></i></span>
|
||||
My Photos
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/photo/upload">
|
||||
<span class="icon"><i class="fa fa-upload"></i></span>
|
||||
Upload Photos
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/photo/private">
|
||||
<span class="icon"><i class="fa fa-eye"></i></span>
|
||||
Manage Private Photos
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/settings">
|
||||
<span class="icon"><i class="fa fa-edit"></i></span>
|
||||
Edit Profile & Settings
|
||||
</a>
|
||||
</li>
|
||||
{{if .CurrentUser.IsInnerCircle}}
|
||||
<li>
|
||||
<a href="/inner-circle">
|
||||
<span class="icon"><img src="/static/img/circle-16.png"></span>
|
||||
{{PrettyCircle}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
<li>
|
||||
<a href="/photo/certification">
|
||||
<span class="icon"><i class="fa fa-certificate"></i></span>
|
||||
Certification Photo
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/users/blocked">
|
||||
<span class="icon"><i class="fa fa-hand"></i></span>
|
||||
Blocked Users
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/notes/me">
|
||||
<span class="icon"><i class="fa fa-pen-to-square mr-1"></i></span>
|
||||
My User Notes
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/logout">
|
||||
<span class="icon"><i class="fa fa-arrow-right-from-bracket"></i></span>
|
||||
Log out
|
||||
</a>
|
||||
</li>
|
||||
{{if .SessionImpersonated}}
|
||||
<li>
|
||||
<a href="/admin/unimpersonate" class="has-text-danger">
|
||||
<span class="icon"><i class="fa fa-ghost"></i></span>
|
||||
<span>Unimpersonate</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
<li>
|
||||
<a href="/settings#deactivate">
|
||||
<span class="icon"><i class="fa fa-trash"></i></span>
|
||||
Delete account
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -257,7 +271,7 @@
|
|||
<div class="column">
|
||||
<div class="card" id="notifications">
|
||||
<header class="card-header has-background-warning">
|
||||
<p class="card-header-title has-text-dark-dark">Notifications</p>
|
||||
<p class="card-header-title">Notifications</p>
|
||||
</header>
|
||||
|
||||
<div class="card-content">
|
||||
|
@ -321,12 +335,140 @@
|
|||
<hr>
|
||||
</div>
|
||||
|
||||
<p class="block">
|
||||
<a href="/settings#notifications">
|
||||
<i class="fa fa-gear mr-1"></i>
|
||||
Manage notification settings
|
||||
</a>
|
||||
</p>
|
||||
<!-- Filters -->
|
||||
<div class="block">
|
||||
<form action="{{.Request.URL.Path}}" method="GET">
|
||||
|
||||
<div class="card nonshy-collapsible-mobile nonshy-collapsible-always mb-5">
|
||||
<header class="card-header has-background-link-light">
|
||||
<p class="card-header-title has-text-dark">
|
||||
<i class="fa fa-list mr-2"></i> Notification Types
|
||||
</p>
|
||||
<button class="card-header-icon" type="button">
|
||||
<span class="icon">
|
||||
<i class="fa fa-angle-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
|
||||
<p class="block">
|
||||
<a href="/settings#notifications">
|
||||
<i class="fa fa-gear mr-1"></i>
|
||||
Manage notification settings
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div class="columns is-multiline mb-0">
|
||||
|
||||
<div class="column is-half">
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox"
|
||||
name="likes"
|
||||
value="true"
|
||||
{{if .Filters.Likes}}checked{{end}}
|
||||
>
|
||||
Likes
|
||||
<p class="help">
|
||||
on your photos, profile or comments
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-half">
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox"
|
||||
name="comments"
|
||||
value="true"
|
||||
{{if .Filters.Comments}}checked{{end}}
|
||||
>
|
||||
Comments
|
||||
<p class="help">
|
||||
on your photos
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-half">
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox"
|
||||
name="photos"
|
||||
value="true"
|
||||
{{if .Filters.NewPhotos}}checked{{end}}
|
||||
>
|
||||
New Photos
|
||||
<p class="help">
|
||||
of your friends
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-half">
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox"
|
||||
name="replies"
|
||||
value="true"
|
||||
{{if .Filters.AlsoCommented}}checked{{end}}
|
||||
>
|
||||
Replies
|
||||
<p class="help">
|
||||
on comment threads you follow
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-half">
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox"
|
||||
name="private"
|
||||
value="true"
|
||||
{{if .Filters.PrivatePhoto}}checked{{end}}
|
||||
>
|
||||
Private photos
|
||||
<p class="help">
|
||||
unlock notifications
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-half">
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox"
|
||||
name="misc"
|
||||
value="true"
|
||||
{{if .Filters.Misc}}checked{{end}}
|
||||
>
|
||||
Miscellaneous
|
||||
<p class="help">
|
||||
new friends, certification photos, etc.
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block has-text-centered">
|
||||
<a href="{{.Request.URL.Path}}" class="button">
|
||||
Reset
|
||||
</a>
|
||||
<button type="submit" class="button is-success">
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table is-striped is-fullwidth is-hoverable">
|
||||
<tbody>
|
||||
|
@ -519,7 +661,8 @@
|
|||
<div class="column is-one-quarter is-clipped">
|
||||
<!-- GIF video? -->
|
||||
{{if HasSuffix $Body.Photo.Filename ".mp4"}}
|
||||
<video loop controls
|
||||
<video loop controls controlsList="nodownload"
|
||||
{{if $Body.Photo.AltText}}title="{{$Body.Photo.AltText}}"{{end}}
|
||||
{{if BlurExplicit $Body.Photo}}class="blurred-explicit"
|
||||
{{else if (not (eq ($Root.CurrentUser.GetProfileField "autoplay_gif") "false"))}}autoplay
|
||||
{{end}}>
|
||||
|
@ -532,7 +675,9 @@
|
|||
</div>
|
||||
{{else}}
|
||||
<a href="/photo/view?id={{$Body.Photo.ID}}">
|
||||
<img src="{{PhotoURL $Body.Photo.Filename}}" loading="lazy"{{if BlurExplicit $Body.Photo}} class="blurred-explicit"{{end}}>
|
||||
<img src="{{PhotoURL $Body.Photo.Filename}}" loading="lazy"
|
||||
{{if BlurExplicit $Body.Photo}} class="blurred-explicit"{{end}}
|
||||
{{if $Body.Photo.AltText}}title="{{$Body.Photo.AltText}}" alt="{{$Body.Photo.AltText}}"{{end}}>
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/photo/u/{{.User.Username}}">
|
||||
<a href="/u/{{.User.Username}}/photos">
|
||||
<span class="icon is-small">
|
||||
<i class="fa fa-image"></i>
|
||||
</span>
|
||||
|
@ -39,7 +39,7 @@
|
|||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/notes/u/{{.User.Username}}">
|
||||
<a href="/u/{{.User.Username}}/notes">
|
||||
<span class="icon is-small">
|
||||
<i class="fa fa-pen-to-square"></i>
|
||||
</span>
|
||||
|
@ -50,7 +50,7 @@
|
|||
</a>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<a href="/friends/u/{{.User.Username}}">
|
||||
<a href="/u/{{.User.Username}}/friends">
|
||||
<span class="icon is-small">
|
||||
<i class="fa fa-user-group"></i>
|
||||
</span>
|
||||
|
@ -69,6 +69,14 @@
|
|||
<div class="block">
|
||||
Found {{.Pager.Total}} friend{{Pluralize64 .Pager.Total}}
|
||||
(page {{.Pager.Page}} of {{.Pager.Pages}}).
|
||||
|
||||
<!-- Admin links -->
|
||||
{{if .CurrentUser.IsAdmin}}
|
||||
<a href="/admin/changelog?table_name=friends&about_user_id={{.User.ID}}" class="button is-small has-text-warning ml-2">
|
||||
<span class="icon"><i class="fa fa-peace"></i></span>
|
||||
<span>Change Log</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
|
@ -93,7 +101,7 @@
|
|||
<!-- Friendship badge -->
|
||||
{{if $Root.FriendMap.Get .ID}}
|
||||
<div class="has-text-centered">
|
||||
<span class="is-size-7 has-text-warning-dark">
|
||||
<span class="is-size-7 has-text-warning">
|
||||
<i class="fa fa-user-group" title="Friends"></i>
|
||||
Friends
|
||||
</span>
|
||||
|
|
|
@ -32,28 +32,39 @@
|
|||
in the description on this page:
|
||||
</p>
|
||||
|
||||
<ul class="menu-list block">
|
||||
<li>
|
||||
<a href="/photo/gallery?visibility=circle">
|
||||
<i class="fa fa-image mr-1"></i>
|
||||
Gallery (circle-only photos)
|
||||
</a>
|
||||
</li>
|
||||
<aside class="menu">
|
||||
<ul class="menu-list block">
|
||||
<li>
|
||||
<a href="/photo/gallery?visibility=circle">
|
||||
<i class="fa fa-image mr-1"></i>
|
||||
Gallery (circle-only photos)
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="/members?certified=circle">
|
||||
<i class="fa fa-people-group mr-1"></i>
|
||||
Inner circle members
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/members?certified=circle">
|
||||
<i class="fa fa-people-group mr-1"></i>
|
||||
Inner circle members
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="/f/circle">
|
||||
<i class="fa fa-comments mr-1"></i>
|
||||
Forum (Circle Chat)
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<li>
|
||||
<a href="/f/circle">
|
||||
<i class="fa fa-comments mr-1"></i>
|
||||
Forum (Circle Chat)
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{{if .CurrentUser.Explicit}}
|
||||
<li>
|
||||
<a href="/f/circle-jerk">
|
||||
<i class="fa fa-comments mr-1"></i>
|
||||
Forum (Circle Jerk) <small class="fa fa-fire has-text-danger" title="Explicit"></small>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -73,6 +84,14 @@
|
|||
sharing nude pics with face for other nonshy nudists to see.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
As a member of the inner circle, you can see who else on the website is a part of the circle. Look
|
||||
for the <img src="/static/img/circle-16.png" alt="Inner circle"> icon on profile pages, the
|
||||
member directory, forums or the chat room. You may find that some of your {{PrettyTitle}} friends
|
||||
were already in the circle! People who are <em>not</em> in the circle won't see these indicators
|
||||
anywhere on the website.
|
||||
</p>
|
||||
|
||||
<h2>What can I do for being in the inner circle?</h2>
|
||||
|
||||
<p>
|
||||
|
@ -99,9 +118,15 @@
|
|||
</li>
|
||||
<li>
|
||||
On the
|
||||
<a href="/members"><strong><i class="fa fa-comments mr-1"></i> Forums</strong></a>
|
||||
<a href="/f/circle"><strong><i class="fa fa-comments mr-1"></i> Forums</strong></a>
|
||||
you can access exclusive inner circle-only boards.
|
||||
</li>
|
||||
<li>
|
||||
On the
|
||||
<a href="/chat"><strong><i class="fa fa-message"></i> Chat Room</strong></a> you have access
|
||||
to the exclusive Circle Chat channel, and you can see who else in the chat room is a part of
|
||||
the inner circle. People outside the circle can't see who the inner circle members are!
|
||||
</li>
|
||||
<li>
|
||||
On your <a href="/u/{{.CurrentUser.Username}}">profile page</a> you get an "Inner circle" badge near your
|
||||
Certified status. This badge is <strong>only</strong> visible to members of the inner circle.
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue