Compare commits

...

75 Commits

Author SHA1 Message Date
Noah Petherbridge 12a1adc270 Grammar update 2024-05-13 21:04:38 -07:00
Noah Petherbridge a284aab026 Update the scam detection DM disclaimer 2024-05-13 20:55:46 -07:00
Noah Petherbridge b477ad5e73 Revise wording on scam disclaimer message in DMs 2024-05-13 20:51:58 -07:00
Noah Petherbridge c566e444c7 Warn recipient in DMs about possible scams 2024-05-13 19:41:11 -07:00
Noah Petherbridge ed008a99e6 Admin: don't search for banned users without the scope
An admin must have the admin.user.ban scope in order to search for
banned or disabled users in the member directory.
2024-05-11 14:10:59 -07:00
Noah Petherbridge 7c7d3a11e5 No need to show emails on Admin Scopes page 2024-05-11 13:54:12 -07:00
Noah Petherbridge 9db7343370 Private forums (admin only for now) 2024-05-11 12:23:06 -07:00
Noah Petherbridge 0f6dd58c54 Remove iPad disclaimer on the chat landing page 2024-05-09 21:23:44 -07:00
Noah Petherbridge 20d04fc370 Admin Transparency Page
* Add a transparency page where regular user accounts can list the roles and
  permissions that an admin user has access to. It is available by clicking on
  the "Admin" badge on that user's profile page.
* Add additional admin scopes to lock down more functionality:
  * User feedback and reports
  * Change logs
  * User notes and admin notes
* Add friendly descriptions to what all the scopes mean in practice.
* Don't show admin notification badges to admins who aren't allowed to act on
  those notifications.
* Update the admin dashboard page and documentation for admins.
2024-05-09 15:50:46 -07:00
Noah Petherbridge 31ba987d62 Update privacy policy 2024-04-29 19:54:59 -07:00
Noah Petherbridge fdf0aee5da Certification Photo to log IP address in changelog 2024-04-28 11:27:06 -07:00
Noah Petherbridge 198849eebc Correctly revoke AlsoPosted notification on comment deletion 2024-04-27 19:46:22 -07:00
Noah Petherbridge a00aec7488 Add Quote & Reply buttons to photo comment pages 2024-04-27 19:17:33 -07:00
Noah Petherbridge 2f352f8664 Ability to find your "Likes" on the Site Gallery 2024-04-27 19:06:17 -07:00
Noah Petherbridge 04f1c56809 Update privacy policy 2024-04-25 22:37:22 -07:00
Noah Petherbridge 106ca56198 Search users by admin, privacy policy update 2024-04-25 21:52:43 -07:00
Noah Petherbridge ff2eb285eb Collect distinct visitor IP addresses 2024-04-25 18:55:02 -07:00
Noah Petherbridge 382c6df96c Fix User model json fields 2024-04-25 11:31:04 -07:00
Noah Petherbridge 19d06c183f Remove debug testing 2024-04-24 20:38:07 -07:00
Noah Petherbridge f4721d65da HTMX lazy load for user statistics card 2024-04-24 20:36:37 -07:00
Noah Petherbridge e7f7f4d0d3 Fix Bulma menu-list on settings page 2024-04-18 20:21:22 -07:00
Noah Petherbridge a0f41074bd Bulma list syntax fixes on a couple pages 2024-04-18 20:18:55 -07:00
Noah Petherbridge 4623cdca50 Update some text copy 2024-04-13 15:10:15 -07:00
Noah Petherbridge e947a005d9 Dark theme color improvements 2024-04-13 14:55:25 -07:00
Noah Petherbridge 32b054cacf Remove from inner circle when deleting all your pictures 2024-04-13 10:44:09 -07:00
Noah Petherbridge 6866bec972 Dark theme text colors on FAQ page 2024-04-12 19:31:45 -07:00
Noah Petherbridge 7dc1ebd63f Slight copy update on home page and FAQ 2024-04-12 19:10:49 -07:00
Noah Petherbridge 360ad41543 Marketing overhaul for the front home page 2024-04-12 18:31:11 -07:00
Noah Petherbridge 2126c5ab84 Clear BareRTC DMs history on account deletion 2024-04-11 23:27:20 -07:00
Noah Petherbridge 2f75059623 Highlight DM privacy feature on inbox page 2024-04-06 15:07:10 -07:00
Noah Petherbridge 763b9e4404 Text search for the change log 2024-04-04 23:05:16 -07:00
Noah Petherbridge 268a177412 Handy contextual links on the admin change log page 2024-04-04 22:48:46 -07:00
Noah Petherbridge ddd33aad91 Change Log Buttons
* Dark theme fixes to brighten notification colors on mobile
* Add change log buttons around various pages to easily look into the history
  of an object in the database:
  * User profile page ('about user' and user table history links)
  * User friends page
  * User/Site gallery page (history of all (user) photos)
  * Admin insights page (comments, threads, and blocklist history)
  * Admin certification page (history of a user's cert photos)
  * Comment history buttons on forums and photos
2024-04-04 22:24:35 -07:00
Noah Petherbridge 4ff7bc5d04 Small color tweak 2024-04-01 20:03:37 -07:00
Noah Petherbridge a47202d756 Tweak navbar notification color 2024-04-01 19:50:59 -07:00
Noah Petherbridge ff69b8f771 Small color tweak 2024-04-01 18:03:16 -07:00
Noah Petherbridge c8238c1749 Fix theme import for prefers-dark 2024-04-01 17:59:57 -07:00
Noah Petherbridge 58eaf53694 Dark theme fixes for Microsoft Edge 2024-04-01 17:53:19 -07:00
Noah Petherbridge 6a483929d2 CSS fixes for forced light theme 2024-04-01 09:27:05 -07:00
Noah Petherbridge fe2e43245b Dark theme fixes 2024-03-30 16:11:55 -07:00
Noah Petherbridge a669b58c55 Various dark theme color fixes 2024-03-30 15:59:29 -07:00
Noah Petherbridge ad59440b2b Forum color tweaks for new dark theme 2024-03-30 14:09:16 -07:00
Noah Petherbridge 2d0fd25a08 Upgrade to Bulma CSS 1.0 and theme picker support 2024-03-30 13:49:36 -07:00
Noah Petherbridge 535e96b491 Rate limit the user Mark Explicit endpoint 2024-03-29 22:59:13 -07:00
Noah Petherbridge 2ab34a39a3 Better UX for Who's Nearby feature 2024-03-29 20:35:41 -07:00
Noah Petherbridge d4e3aa755b Style update for certification checklist 2024-03-28 23:18:23 -07:00
Noah Petherbridge 4f3f6de158 Bugfix on gallery page 2024-03-28 23:06:58 -07:00
Noah Petherbridge 35258beb36 Helpful copy and image lightbox fix
* On the "Certification Required" error page: show help text under the
  checklist to make it clear that clicking the checklist item will link
  to the profile photo or cert photo upload page.
* Add more helpful text around the site to address common confusion:
  * On the Photo Upload page for profile_pic and the user is already at
    quota: add special text saying they can use an existing gallery
    photo as their profile pic instead.
  * On the self Gallery view page: if the user has no profile pic
    currently set, offer advice and links on how to set one.
* Fix the image max-width on Gallery lightbox modals
2024-03-28 23:02:42 -07:00
Noah Petherbridge 1c2982aec0 Quick Mark Explicit: Not on user's own pictures 2024-03-19 19:41:17 -07:00
Noah Petherbridge d623f0bc3c User endpoint to flag photos that should be Explicit 2024-03-16 13:29:28 -07:00
Noah Petherbridge 04a7616299 Alt Text Tweaks + Video site link detection 2024-03-15 23:19:26 -07:00
Noah Petherbridge 9c4ec85f8a Bugfix with photo alt text not saving on new upload 2024-03-15 22:42:38 -07:00
Noah Petherbridge cf6249c415 Alt Text for Photos
* Add an Alt Text field for users to describe their photos for accessibility.
* Alt texts appear on mouse over on Gallery pages, in the lightbox modal (on
  mouse over or by clicking the ALT button that appears), and in a box on the
  permalink page below the photo caption.
* Max length of Alt Text is 5,000 characters.
* Fix a bug with the right-click blocker not working on the lightbox modal.
2024-03-15 22:02:24 -07:00
Noah Petherbridge 742a5fa1af Auto-Disconnect Users from Chat
Users whose accounts are no longer eligible to be in the chat room will be
disconnected immediately from chat when their account status changes.

The places in nonshy where these disconnects may happen include:

* When the user deactivates or deletes their account.
* When they modify their settings to mark their profile as 'private,' making
  them become a Shy Account.
* When they edit or delete their photos in case they have moved their final
  public photo to be private, making them become a Shy Account.
* When the user deletes their certification photo, or uploads a new cert photo
  to be reviewed (in both cases, losing account certified status).
* When an admin user rejects their certification photo, even retroactively.
* On admin actions against a user, including: banning them, deleting their
  user account.

Other changes made include:

* When signing up an account and e-mail sending is not enabled (e.g. local
  dev environment), the SignupToken is still created and logged to the console
  so you can continue the signup manually.
* On the new account DOB prompt, add a link to manually input their birthdate
  as text similar to on the Age Gate page.
2024-03-15 15:57:05 -07:00
Noah Petherbridge be9276f4c0 Better photo scaling without scroll in lightbox modal 2024-03-07 18:10:29 -08:00
Noah Petherbridge 80c4471017 Add DB indexes and request time to page footer 2024-03-03 17:58:18 -08:00
Noah Petherbridge 28111585ef Notification Filters 2024-02-28 20:49:16 -08:00
Noah Petherbridge dd24aa1987 Quick mark Explicit link on photo permalink page 2024-02-25 17:40:23 -08:00
Noah Petherbridge 2820cf581e Dedicated ChangeLog events for ban/lifecycle/admin changes 2024-02-25 17:36:01 -08:00
Noah Petherbridge 3142e0ce84 Change Log Updates
* Delete all change logs AboutUserID on account deletion, and export
  them in the data export zip.
* Log admin changes to ban/admin status of other users.
* Log user deactivations/reactivations and deletions (self serve or
  admin deletion).
2024-02-25 17:28:40 -08:00
Noah Petherbridge f4d176a538 Change Logs
* Add a ChangeLog table to collect historic updates to various database tables.
* Created, Updated (with field diffs) and Deleted actions are logged, as well
  as certification photo approves/denies.
* Specific items added to the change log:
  * When a user photo is marked Explicit by an admin
  * When users block/unblock each other
  * When photo comments are posted, edited, and deleted
  * When forums are created, edited, and deleted
  * When forum comments are created, edited and deleted
  * When a new forum thread is created
  * When a user uploads or removes their own certification photo
  * When an admin approves or rejects a certification photo
  * When a user uploads, modifies or deletes their gallery photos
  * When a friend request is sent
  * When a friend request is accepted, ignored, or rejected
  * When a friendship is removed
2024-02-25 17:03:36 -08:00
noah 85d2f4eee9 Merge pull request 'Deduplicate threads on Newest forum tab' (#38) from deduplicate-forum-newest into main
Reviewed-on: #38
2024-02-16 03:56:15 +00:00
Noah Petherbridge 62d56d5924 Better "Newest" tab for forums 2024-02-15 19:53:25 -08:00
Noah Petherbridge 3c0473c633 WIP: Deduplicate threads on Newest forum tab 2024-02-14 21:38:20 -08:00
Noah Petherbridge 7ceb14053b Don't show banned friends on friend page 2024-02-14 20:25:24 -08:00
Noah Petherbridge 7da650ffc4 Go 1.22 upgrade 2024-02-10 16:17:15 -08:00
Noah Petherbridge 588de52252 Add orientation options 2024-02-08 13:02:19 -08:00
Noah Petherbridge 211c649f9a No downloading of GIF videos in Chrome 2024-01-27 14:44:30 -08:00
Noah Petherbridge fedfbed4eb Ability to change username 2024-01-27 13:57:24 -08:00
Noah Petherbridge ef8abec7bf Fix removing likes notification 2024-01-20 15:08:36 -08:00
Noah Petherbridge a9cc758624 On signup: tell user to check their spam folder too for the email 2024-01-13 11:26:47 -08:00
Noah Petherbridge 20d9bf7768 Fix error on FAQ when logged out 2024-01-12 16:59:01 -08:00
Noah Petherbridge f27b41a214 Update PWA manifest for app name 2024-01-11 19:17:54 -08:00
Noah Petherbridge b4cd57c8c3 Tweak friends-only pic notification revoke 2024-01-10 18:08:17 -08:00
Noah Petherbridge eed971d997 FAQ update and notifications fix 2024-01-10 17:47:41 -08:00
137 changed files with 27707 additions and 10202 deletions

80
go.mod
View File

@ -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
View File

@ -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=

221
pkg/chat/chat_api.go Normal file
View File

@ -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
}

View File

@ -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]
}

View File

@ -10,7 +10,7 @@ import (
// returned by the scope list function.
func TestAdminScopesCount(t *testing.T) {
var scopes = config.ListAdminScopes()
if len(scopes) != config.QuantityAdminScopes {
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",

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -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.")
})
}

View File

@ -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
}

View File

@ -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)

View File

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

View File

@ -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,

View File

@ -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.

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

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

View File

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

View File

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

View File

@ -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:

View File

@ -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{

View File

@ -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,
})
})
}

View File

@ -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.

View File

@ -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

View File

@ -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{

View File

@ -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)

View File

@ -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 {

View File

@ -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.

View File

@ -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
}

View File

@ -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)

View File

@ -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)

View File

@ -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!")
}

View File

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

View File

@ -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")

View File

@ -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)

View File

@ -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 {

View File

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

View File

@ -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

View File

@ -64,7 +64,7 @@ func SiteGallery() http.HandlerFunc {
// They didn't post a "Whose photos" filter, restore it from their last saved default.
who = currentUser.GetProfileField("site_gallery_default")
}
if who != "friends" && who != "everybody" && who != "friends+private" {
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.

View File

@ -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
}

View File

@ -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 {

View File

@ -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",

View File

@ -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)

View File

@ -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))

254
pkg/models/change_log.go Normal file
View File

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

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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)
}

View File

@ -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(

View File

@ -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
}

View File

@ -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 ?")

View File

@ -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)

View File

@ -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)

View File

@ -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
}

View File

@ -9,11 +9,11 @@ import (
// Like table.
type Like struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"index"` // who it belongs to
TableName string
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
}

View File

@ -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)

View File

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

View File

@ -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...,

View File

@ -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
}

View File

@ -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 ?")

View File

@ -61,7 +61,7 @@ func RevokePrivatePhotosAll(sourceUserID uint64) error {
// RevokePrivatePhotoNotifications removes notifications about newly uploaded private photos
// that were sent to one (or multiple) members when the user revokes their access later. Pass
// a nil fromUserID to revoke the photo upload notifications from ALL users.
func RevokePrivatePhotoNotifications(currentUser *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)
}
}

View File

@ -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)

View File

@ -17,10 +17,10 @@ import (
// User account table.
type User struct {
ID uint64 `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex"`
Email string `gorm:"uniqueIndex"`
HashedPassword string
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)

View File

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

View File

@ -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)

View File

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

32
pkg/spam/spam.go Normal file
View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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

View File

@ -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",
}

View File

@ -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)

View File

@ -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

22437
web/static/css/bulma.css vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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;
}

View File

@ -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);

View File

@ -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

View File

@ -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);

1
web/static/js/htmx-1.9.12.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -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();
});
});
});

View File

@ -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();

View File

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

View File

@ -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;

View File

@ -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 &amp; 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 &amp; 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 &amp; 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}}

View File

@ -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>

View File

@ -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