Compare commits

..

191 Commits

Author SHA1 Message Date
Noah Petherbridge
63471e2e9b Show GIFs properly on add/edit forum post page 2024-11-12 20:10:40 -08:00
Noah Petherbridge
ad0eb6e17c Dark hero.is-light banner for dark theme 2024-10-19 16:07:52 -07:00
Noah Petherbridge
7ffcd6b3a8 Photo Views: Count a mouseover as a view too
For video elements (animated GIFs), since the 'click' for lightbox modal
doesn't work, mouseover and play/pause count as views. This can unfairly
lead videos to climb as the most viewed images while pictures need a
click or a 'like' to count.

So, count images as viewed on their mouseover event as well.
2024-10-19 15:44:50 -07:00
Noah Petherbridge
b52d9df958 Member Search: Add a filter for "Currently on chat" 2024-10-19 13:07:17 -07:00
Noah Petherbridge
1b3e8cb250 Private Photo Sharing Improvements
* Add a user privacy setting so they can gate who is allowed to share private
  photos with them (for people who dislike unsolicited shares):
  * Anybody (default)
  * Friends only
  * Friends + people whom they have sent a DM to (on the main website)
  * Nobody
* Add gating around whether to display the prompt to unlock your private photos
  while you are viewing somebody's gallery:
  * The current user needs at least one private photo to share.
  * The target user's new privacy preference is taken into consideration.
* The "should show private photo share prompt" logic is also used on the actual
  share page, e.g. for people who manually paste in a username to share with.
  You can not grant access to private photos which don't exist.
* Improve the UI on the private photo shares page.
  * Profile cards to add elements from the Member Directory page, such as a
    Friends and Liked indicator.
  * A count of the user's Private photos is shown, which links directly to
    their private gallery.
* Add "Decline" buttons to the Shared With Me page: so the target of a private
  photo share is able to remove/cancel shares with them.
2024-10-19 12:44:47 -07:00
Noah Petherbridge
e146c09850 Improvements to Feedback & Reports
* Add an AboutUserID field to feedbacks, so when the report is about a
  picture that is later deleted, the feedback can still link to the
  original owner's account instead of showing an error.
* Add filters to the User Notes page so the admin can see:
  * All feedback From or About the user or their content (default)
  * Feedback created by the user
  * Feedback about the user or their content
  * Fuzzy search for any feedback containing the user's name.
* On chat room reports: make the @channel ID a clickable user profile
  link for convenience.
2024-10-17 19:21:18 -07:00
Noah Petherbridge
704124157d User Themes refactor
Instead of fighting to override Bulma CSS classes, add user-theme-*
classes for simpler styling.
2024-10-13 20:31:09 -07:00
Noah Petherbridge
b7bee75e1f Function to re-sign photo URLs on profile pages
* With the new JWT signatures on photo URLs, it was no longer possible for
  creative users to embed their gallery photos on their profile page.
* Add a function to ReSignPhotoLinks that finds/replaces (on the server side)
  all references to paths under "/static/photos/" and gives them a fresh
  ?jwt= query string signature.
* Note: only applies to the profile page essays, ReSignPhotoLinks is a
  template func that must be opted-in on a per page basis.

Other miscellaneous fixes

* Add "Edit" buttons in the corners of profile cards, when the current user
  looks at their profile page. They link to URIs like
  "/settings#profile/about_me" which will now:
  1. Select the "Profile settings" tab like #profile
  2. Scroll and focus the profile essay field that the user clicked to edit.
2024-10-13 19:50:11 -07:00
Noah Petherbridge
cb37934935 Birthday chat room style fix
The h2 was showing in light text on dark mode making it unreadable.
2024-10-12 13:14:40 -07:00
Noah Petherbridge
26f9c4d71d Minor bugfix 2024-10-07 16:50:21 +00:00
Noah Petherbridge
2262edfe09 Improve browser caching with signed JWT photo URLs
* JWT tokens will now expire on the 10th of the next month, to produce
  consistent values for a period of time and aid with browser caching.
2024-10-05 20:24:45 -07:00
Noah Petherbridge
77a9d9a7fd Code cleanup 2024-10-04 21:22:52 -07:00
Noah Petherbridge
8078ff8755 Batch Edit/Delete Photos + Misc Fixes
Certification Required page:

* Show helpful advice if the reason for the page is only that the user had
  deleted their default profile pic, but their account was certified.

Batch Photo Delete & Visibility:

* On user galleries, owners and admins can batch Delete or Set Visibility on
  many photos at once. Checkboxes appear in the edit/delete row of each photo,
  and bulk actions appear at the bottom of the page along with select/unselect
  all boxes.
* Deprecated the old /photo/delete endpoint: it now redirects to the batch
  delete page with the one photo ID.

Misc Changes:

* Notifications now sort unread to the top always.
2024-10-04 21:17:20 -07:00
Noah Petherbridge
cbdabe791e Improve Signed Photo URLs
* The photo signing JWT tokens carry more fields to validate against:
  * The username the token is assigned to (or '@' for anyone)
  * An 'anyone' boolean for widely public images, such as for the chat room
    and public profile pages.
  * A short filename hash of the image in question (whether a Photo or a
    CommentPhoto) - so that the user can't borrow a JWT token from the chat
    room and reveal a different picture.
* Refactored where the VisibleAvatarURL function lives, to avoid a cyclic
  dependency error.
  * Originally: (*models.User).VisibleAvatarURL(other *models.User)
  * Now: (pkg/photo).VisibleAvatarURL(user, currentUser *models.User)
2024-10-03 20:14:34 -07:00
Noah Petherbridge
7869ff83ba Signed and Authenticated Static Photo URLs
* Add support for authenticated static photo URLs, leveraging the NGINX module
  ngx_http_auth_request. The README is updated with an example NGINX config
  how to set this up on the proxy side.
* In settings.json a new SignedPhoto section is added: not enabled by default.
* PhotoURL will append a ?jwt= token to the /static/photos/ path for the
  current user, which expires after 30 seconds.
* When SignedPhoto is enabled, it will enforce that the JWT token is valid and
  matches the username of the current logged-in user, or else will return with
  a 403 Forbidden error.
2024-10-03 18:04:14 -07:00
Noah Petherbridge
295183559d Admin labels on photos surrounding explicit flags
* Add 'admin labels' to photos so an admin can classify a photo as:
  * Not Explicit: e.g. it was flagged by the community but does not
    actually need to be explicit. This option will hide the prompt to
    report the explicit photo again.
  * Force Explicit: if a user is fighting an explicit flag and keeps
    removing it from their photo, the photo can be force marked
    explicit.
* Admin labels appear on the Permalink page and in the edit photo
  settings when viewed as a photo moderator admin.
2024-10-02 16:22:19 -07:00
Noah Petherbridge
542d0bb300 Improvements on community flagged explicit photos
When a user marks that another photo should have been marked as explicit:

* The owner of that photo gets a notification about it, which reminds them of
  the explicit photo policy.
* The photo's "Flagged" boolean is set (along with the Explicit boolean)
* The 'Edit' page on a Flagged photo shows a red banner above the Explicit
  option, explaining that it was flagged. The checkbox text is crossed-out,
  with a "no" cursor and title text over - but can still be unchecked.

If the user removes the Explicit flag on a flagged photo and saves it:

* An admin report is generated to notify to take a look too.
* The Explicit flag is cleared as normal
* The Flagged boolean is also cleared on this photo: if they set it back to
  Explicit again themselves, the red banner won't appear and it won't notify
  again - unless a community member flagged it again!

Also makes some improvements to the admin page:

* On photo reports: show a blurred-out (clickable to reveal) photo on feedback
  items about photos.
2024-10-01 20:44:11 -07:00
Noah Petherbridge
c8d9cdbb3a Demographics page: count only 'gallery' photos
The photo stats were counting ALL public photos of certified members,
whether featured on the Site Gallery or not. Update the query to filter
for Site Gallery photos instead.
2024-09-28 13:10:56 -07:00
Noah Petherbridge
106bcd377e User Forums: Enable PermitPhotos for all forum owners 2024-09-28 12:58:52 -07:00
Noah Petherbridge
f2e847922f Tweak admin permissions and photo view counts
* Profile pictures on profile pages now link to the gallery when clicked.
* Admins can no longer automatically see the default profile pic on profile
  pages unless they have photo moderator ability.
* Photo view counts are not added when an admin with photo moderator ability
  should not have otherwise been able to see the photo.
2024-09-28 12:45:20 -07:00
Noah Petherbridge
3fdae1d8d7 Various minor tweaks
* Demographics page:
    * Show percents with up to 1 decimal place of precision.
    * On tablets+ align the percent text to the right.
    * On photo counts, only include certified active user photos.
    * On gender/orientation demographics, pad the remaining "No answer" counts
      with the set of users who have no profile_fields set in the database yet.
* Admin certification page:
    * Add additional "common rejection reasons"
    * Add a confirm prompt when viewing the Rejected list to avoid accidental
      approval of previously rejected cert photos.
2024-09-27 17:37:45 -07:00
Noah Petherbridge
0c7fc7e866 Improve message reporting format 2024-09-26 21:05:27 -07:00
Noah Petherbridge
ab880148ad 'Likes' view an image + Tweak inbox page
* Hitting the Like button on a photo will mark it as viewed.
* Move the 'Report' button on the message inbox page, to instead be in
  the footer of each DM.
* Improve message reporting behavior to include the content of the
  message in the admin report.
2024-09-26 20:56:16 -07:00
Noah Petherbridge
7aa1d512fc Photo view count tweaks
* The owner of a photo no longer counts any views on it.
* Add event handlers to mark animated GIFs viewed on the gallery page:
  if the user mouse overs or pauses the video.
2024-09-26 20:32:04 -07:00
Noah Petherbridge
9d6c299fdd Photo View Counters 2024-09-25 22:46:33 -07:00
Noah Petherbridge
955ace1e91 Optimize sorting gallery by Likes/Comments via caching 2024-09-21 17:25:36 -07:00
Noah Petherbridge
0cd72a96ed Optimize gallery sort by likes or comments 2024-09-21 16:59:37 -07:00
Noah Petherbridge
944b2e28e9 Sort Gallery photos by Likes and Comments 2024-09-21 16:39:18 -07:00
Noah Petherbridge
9575041d1e Bugfix when removing all chat moderation rules 2024-09-20 20:32:56 -07:00
Noah Petherbridge
066765d2dc Chat Moderation Rules + Shy Accounts on Chat
* Add chat moderation rules to the website, so admins can apply selective rules
  to problematic users. Available rules are:
  * redcam: user's camera is always NSFW.
  * nobroadcast: user can not broadcast their camera.
  * novideo: user can not broadcast OR watch any video.
  * noimage: user can not share OR see any shared image on chat.
* The page to manage a user's active rules is available on their admin card of
  their profile page. When the user has rules active, a yellow counter is shown
  by the link to manage their rules.
  * Only chat moderator admins have access to the page or can see the yellow
    counter to know whether rules are active.
* "Shy Accounts" are now permitted on the chat room! With some moderation rules
  automatically applied to them: novideo,noimage.
* Update the Shy Account FAQ and messaging on the chat landing page.
* Update the auto-kick from chat behavior regarding shy accounts:
  * They are kicked from chat only when an update to their profile settings will
    transition then FROM a non-shy into a shy account.
  * For example: when saving their profile settings (going private) or when
    editing or deleting a photo (if they will have no more public photos left)
2024-09-19 19:30:02 -07:00
Noah Petherbridge
ae84ddf449 Web Push Notifications: Disable script when impersonated
If an admin needs to impersonate a regular user (to diagnose a support
issue or investigate a reported conversation thread), the web push
script is disabled so that the admin doesn't get subscribed to push
notifications for that user.
2024-09-14 12:07:18 -07:00
Noah Petherbridge
7991320256 Fix SQL queries on demographics page 2024-09-12 10:42:07 -07:00
Noah Petherbridge
02487ba2f4 Adjust progressbar styles 2024-09-11 19:37:10 -07:00
Noah Petherbridge
4b43071f28 Bugfix 2024-09-11 19:35:45 -07:00
Noah Petherbridge
2f31d678d0 Usage Statistics and Website Demographics
Adds two new features to collect and show useful analytics.

Usage Statistics:
* Begin tracking daily active users who log in and interact with major features
  of the website each day, such as the chat room, forum and gallery.

Demographics page:
* For marketing, the home page now shows live statistics about the breakdown of
  content (explicit vs. non-explicit) on the site, and the /insights page gives
  a lot more data in detail.
* Show the percent split in photo gallery content and how many users opt-in or
  share explicit content on the site.
* Show high-level demographics of the members (by age range, gender, orientation)

Misc cleanup:
* Rearrange model list in data export to match the auto-create statements.
* In data exports, include the forum_memberships, push_notifications and
  usage_statistics tables.
2024-09-11 19:28:52 -07:00
Noah Petherbridge
8d9588b039 Notification when admin users are blocked 2024-09-10 15:43:34 -07:00
Noah Petherbridge
79ea384d40 More adjusting email sending behavior 2024-09-09 20:59:46 -07:00
Noah Petherbridge
463253dbb5 Email delivery tweaks 2024-09-09 20:52:53 -07:00
Noah Petherbridge
276eddfd8e Search and filter admin feedback & reports 2024-09-07 14:50:11 -07:00
Noah Petherbridge
2c7532434a My List: show owned forums only when not official forums 2024-08-30 18:49:12 -07:00
Noah Petherbridge
b034b1ae6c Feature launch and misc typo fixes 2024-08-31 01:47:03 +00:00
Noah Petherbridge
c37d0298b0 User Forums Blocking Behavior + Misc Fixes
Make some adjustments to blocking behavior regarding the forums:

* Pre-existing bug: on a forum's home page (threads list), if a thread was
  created by a blocked user, the thread still appeared with the user's name and
  picture visible. Now: their picture and name will be "[unavailable]" but the
  thread title/message and link to the thread will remain. Note: in the thread
  view itself, posts by the blocked user will be missing as normal.
* Make some tweaks to allow forum moderators (and owners of user-owned forums)
  able to see messages from blocked users on their forum:
  * In threads: a blocked user's picture and name are "[unavailable]" but the
    content of their message is still shown, and can be deleted by moderators.

Misc fixes:

* Private photos: when viewing your granted/grantee lists, hide users whose
  accounts are inactive or who are blocked.
* CertifiedSince: in case a user was manually certified but their cert photo
  status is not correct, return their user CreatedAt time instead.
2024-08-28 18:42:49 -07:00
Noah Petherbridge
617cd48308 Feature flag 2024-08-26 21:39:07 -07:00
Noah Petherbridge
242333d8b7 Spit and polish
* Show follower counts on forums
* Sort by popularity (follow count)
2024-08-26 21:36:48 -07:00
Noah Petherbridge
56a6190ce9 User Forums: Sort options and pagination
* The Explore tab can now sort forums by their:
  * Most recently updated thread
  * Topics, Posts or Users (counts)
* Show owner information in forum cards
* Passive pagination support for the "My List" on forum home page.
  * Only visible when there are >20 favorited Forums.
2024-08-26 20:47:14 -07:00
Noah Petherbridge
3921691319 Optimize SQL: CountLikesReceived 2024-08-25 20:43:22 -07:00
Noah Petherbridge
e0e98d8df6 Gentle rule reminder below photo comment form 2024-08-25 20:05:20 -07:00
Noah Petherbridge
2cf4405ce0 Tweak PWA mobile loading indicator
* Don't show the loading indicator when intercepted href="#" links have
  been clicked (e.g. like buttons, settings tabs)
2024-08-24 19:08:40 -07:00
Noah Petherbridge
def9f6ddcf Bugfix on member search page 2024-08-23 23:34:12 -07:00
Noah Petherbridge
36e48f6ce0 Member Search: Order by certified at 2024-08-23 23:09:27 -07:00
Noah Petherbridge
81719218e2 Testing feature flags 2024-08-23 22:56:41 -07:00
Noah Petherbridge
de52037fb0 Code cleanup 2024-08-23 22:56:41 -07:00
Noah Petherbridge
85fd6ac5a2 Thread Moderator Buttons: Pin and Lock
* The bottoms of threads have moderator buttons now, to easily Pin or
  Unpin the thread (for Owners + Admins) or to Lock/Unlock the thread
  (all moderators).
2024-08-23 22:56:40 -07:00
Noah Petherbridge
90d0d10ee5 Adopt a Forum
* Forums are disowned on user account deletion (their owner_id=0)
* A forum without an owner shows a notice at the bottom with a link to petition
  to adopt the forum. It goes to the Contact form with a special subject.
  * Note: there is no easy way to re-assign ownership yet other than a direct
    database query.
* Code cleanup
  * Alphabetize the DB.AutoMigrate tables.
  * Delete more things on user deletion: forum_memberships, admin_group_users
  * Vacuum worker to clean up orphaned polls after the threads are removed
2024-08-23 22:56:40 -07:00
Noah Petherbridge
b12390563e Forum Creation Quotas
Add minimum quotas for users to earn the ability to create custom forums.

The entry requirements that could earn the first forum include:
1. Having a Certified account status for at least 45 days.
2. Having written 10 posts or replies in the forums.

Additional quota is granted in increasing difficulty based on the count of
forum posts created.

Other changes:

* Admin view of Manage Forums can filter for official/community.
* "Certified Since" now shown on profile pages.
* Update FAQ page for Forums feature.
2024-08-23 22:56:40 -07:00
Noah Petherbridge
05dc6c0e97 Minor improvements
* Add a "Report" link to the footer of forums.
* Allow some non-admin users to view a private forum and its threads.
  * Moderators and approved followers can see it
  * Note: the endpoint to follow a forum won't let a user invite themselves
    to a private forum. Currently there is no way to approve a user except
    by also adding them as a moderator.
  * Explore and Newest tabs can show these private forums if viewable.
2024-08-23 22:56:40 -07:00
Noah Petherbridge
8a321eb8d2 User-owned Forum Improvements
* Private forums: CanBeSeenBy moderators, approved followers, its owner and
  admin users.
  * Note: the endpoint to subscribe to the forum won't allow users to follow
    the private forum, so approved followers can not be created at this time,
    except by adding them as moderators.
* Admins: when creating a forum they can choose "no category" to create it as
  an unofficial community forum.
* Code cleanup
  * More feature flag checking
2024-08-23 22:56:40 -07:00
Noah Petherbridge
28d1e284ab User Forums: Newest Tab, Moderators
* The "Newest" tab of the forum is updated with new filter options.
  * Which forums: All, Official, Community, My List
  * Show: By threads, All posts
  * The option for "Which forums" is saved in the user's preferences and set as
    their default on future visits, similar to the Site Gallery "Whose photos"
    option.
  * So users can subscribe to their favorite forums and always get their latest
    posts easily while filtering out the rest.
* Forum Moderators
  * Add the ability to add and remove moderators for your forum.
  * Users are notified when they are added as a moderator.
  * Moderators can opt themselves out by unfollowing the forum.
* ForumMembership: add unique constraint on user_id,forum_id.
2024-08-23 22:56:40 -07:00
Noah Petherbridge
9570129bba Forum Memberships & My List
* Add "Browse" tab to the forums to view them all.
  * Text search
  * Show all, official, community, or "My List" forums.
* Add a Follow/Unfollow button into the header bar of forums to add it to
  "My List"
* On the Categories page, a special "My List" category appears at the top
  if the user follows categories, with their follows in alphabetical order.
* On the Categories & Browse pages: forums you follow will have a green
  bookmark icon by their name.

Permissions:

* The forum owner is able to Delete comments by others, but not Edit.

Notes:

* Currently a max limit of 100 follow forums (no pagination yet).
2024-08-23 22:56:40 -07:00
Noah Petherbridge
1bf846e78b Forum Admin Page for Regular Users
Allow regular (non-admin) users access to the Manage Forums page so they can
create and manage their own forums.

Things that were already working:

* The admin forum page was already anticipating regular LoginRequired credential
* Users only see their owned forums, while admins can see and manage ALL forums

Improvements made to the Forum Admin page:

* Change the title color from admin-red to user-blue.
* Add ability to search (filter) and sort the forums.

Other changes:

* Turn the Forum tab bar into a reusable component.
2024-08-23 22:56:40 -07:00
Noah Petherbridge
ef95d05453 FontAwesome version upgrade to 6.6.0 2024-08-23 22:56:25 -07:00
Noah Petherbridge
5b0f8e7774 Dark Theme fixes for MS Edge 2024-08-22 22:53:37 -07:00
Noah Petherbridge
d11631c574 Theme update 2024-08-22 22:44:19 -07:00
Noah Petherbridge
1f1341b0f7 Adjust spinner and search page 2024-08-22 22:27:21 -07:00
Noah Petherbridge
bf87cf1a2e Custom loading spinner for PWA 2024-08-22 22:19:42 -07:00
Noah Petherbridge
146a537ec4 Web App Manifest: standalone instead of minimal-ui 2024-08-13 20:14:06 -07:00
Noah Petherbridge
a8dda91c3c Debug Notification API failures 2024-08-12 20:41:04 -07:00
Noah Petherbridge
2be90b4a81 Don't send admin emails on new cert photos 2024-08-12 10:59:14 -07:00
Noah Petherbridge
e70ede301f Delete the inner circle 2024-08-10 11:54:37 -07:00
Noah Petherbridge
1936bcde37 Dropdown menus to open up near bottom of the page 2024-08-10 11:18:52 -07:00
Noah Petherbridge
a00851a8b2 Formatting fix 2024-08-07 23:33:45 -07:00
Noah Petherbridge
a6bd33fdf8 Fix mute notification links on unread notifications 2024-08-07 23:30:19 -07:00
Noah Petherbridge
147a9162ba Mute specific friends new photo upload notifications 2024-08-07 23:05:23 -07:00
Noah Petherbridge
01c38c5c21 Easy comment thread unsubscribe links in Notifications 2024-08-07 22:15:47 -07:00
Noah Petherbridge
f1cf1bd958 Bugfix when linking to 1st comment on secondary pages of thread 2024-08-03 21:00:51 -07:00
Noah Petherbridge
b8be14ea8d Search By Location
* Add a world cities database with type-ahead search on the Member Directory.
* Users can search for a known city to order users by distance from that city
  rather than from their own configured location on their settings page.
* Users must opt-in their own location before this feature may be used, in order
  to increase adoption of the location feature and to enforce fairness.
* The `nonshy setup locations` command can import the world cities database.
2024-08-03 14:54:22 -07:00
Noah Petherbridge
6ca94cb926 Some basic statistics on the home page 2024-07-31 15:32:16 -07:00
Noah Petherbridge
5c5367c557 Fix orphaned comment photo worker 2024-07-26 17:35:49 -07:00
Noah Petherbridge
40b1f2f57a Database cleanup tasks 2024-07-26 17:15:53 -07:00
Noah Petherbridge
188e2e147c Update search keywords 2024-07-26 16:16:10 -07:00
Noah Petherbridge
f3925c1095 Fix inner circle search on member directory 2024-07-20 20:32:19 -07:00
Noah Petherbridge
ec9d2d6939 go mod tidy 2024-07-21 02:48:36 +00:00
Noah Petherbridge
a314aab7ec Web Push Notifications
* Add support for Web Push Notifications when users receive a new Message or
  Friend Request on the main website.
* Users opt in or out of this on their Notification Settings. They can also
  individually opt out of Message and Friend Request push notifications.
2024-07-20 19:44:22 -07:00
Noah Petherbridge
dbeb5060e4 Minor search query bugfix 2024-07-16 17:07:41 -07:00
Noah Petherbridge
80cc5a97ee More search keywords 2024-07-16 11:28:47 -07:00
Noah Petherbridge
a0320714c4 Search terms and admin features 2024-07-13 12:05:36 -07:00
Noah Petherbridge
2f997dfee0 Revise admin options in member directory 2024-07-09 22:27:24 -07:00
Noah Petherbridge
1c01aad80f Normal users can not search non-certified profiles
* Remove the ability for regular (non-admin) users to search the Member
  Directory for non-certified profiles.
* Profiles who don't certify can be a risk to contact, as the likelihood
  of fake pictures and scams/spam is much higher.
2024-07-09 22:21:28 -07:00
Noah Petherbridge
3203a487a5 Minor bugfix: set type=button on the paginator overflow button 2024-07-08 18:00:23 +00:00
Noah Petherbridge
9b72a95ca4 Pinned photo bugfixes on initial upload page 2024-07-07 14:19:51 -07:00
Noah Petherbridge
22b8cf0594 Pinned photo gallery support 2024-07-07 14:00:58 -07:00
Noah Petherbridge
0db69983fe Revise Pagination Widget
The pager widget will now show a dropdown menu of overflow pages in the
middle. This allows easy access to the First and Last pages and an
ability to select from any of the middle pages to jump to quickly.
2024-07-07 12:45:42 -07:00
Noah Petherbridge
1134128a71 Revert "Public Avatar Consent Page"
This reverts commit 4f04323d5a.
2024-06-29 21:42:35 -07:00
Noah Petherbridge
656710035b Revert "Add notification banners about upcoming Public Avatar change"
This reverts commit 91a3cc27ba.
2024-06-29 20:52:23 -07:00
Noah Petherbridge
91a3cc27ba Add notification banners about upcoming Public Avatar change 2024-06-29 19:28:51 -07:00
Noah Petherbridge
4f04323d5a Public Avatar Consent Page
The nonshy website is changing the policy on profile pictures. From August 30,
the square cropped avatar images will need to be publicly viewable to everyone.

This implements the first pass of the rollout:

* Add the Public Avatar Consent Page which explains the change to users and
  asks for their acknowledgement. The link is available from their User Settings
  page, near their Certification Photo link.
* When users (with non-public avatars) accept the change: their square cropped
  avatar will become visible to everybody, instead of showing a placeholder
  avatar.
* Users can change their mind and opt back out, which will again show the
  placeholder avatar.
* The Certification Required middleware will automatically enforce the consent
  page once the scheduled go-live date arrives.

Next steps are:

1. Post an announcement on the forum about the upcoming change and link users
   to the consent form if they want to check it out early.
2. Update the nonshy site to add banners to places like the User Dashboard for
   users who will be affected by the change, to link them to the forum post
   and the consent page.
2024-06-29 16:44:18 -07:00
Noah Petherbridge
a82e04b2f8 Change Likes icon 2024-06-26 21:28:53 -07:00
Noah Petherbridge
8754ed8592 Search users by "Liked" 2024-06-26 21:27:03 -07:00
Noah Petherbridge
72b6e2a616 Profile light/dark theme CSS fixes 2024-06-24 23:07:26 -07:00
Noah Petherbridge
a0f3083e15 Update wording on the Shy Account chat kick message 2024-06-19 15:50:34 -07:00
Noah Petherbridge
88663d48a4 Member Search: Usually apply certified-only filter
Always filter for certified members unless the user specifically
searches for non-certified or "all users".

Admin searches for banned/disabled also search all users.
2024-06-19 15:03:58 -07:00
Noah Petherbridge
02ec0a9116 Optimize Site Gallery query for massive friend lists
Instead of selecting the full array of friend user IDs and pasting that
into the main query, use subselects for hopefully better performance.
2024-06-19 14:42:57 -07:00
Noah Petherbridge
616f6ae76b Full text profile search for the member directory 2024-06-19 14:12:25 -07:00
Noah Petherbridge
6ac121b345 Rename username field on Member Directory 2024-06-15 17:10:43 -07:00
Noah Petherbridge
42aeb60853 Various tweaks and improvements
* Inner circle: users have the ability to remove themselves and can avoid being
  invited again in the future.
* Admin actions: add a "Reset Password" ability to user accounts.
* Admin "Create New User" page.
* Rate limit error handling improvements for the login page.
2024-06-15 15:05:50 -07:00
Noah Petherbridge
6d46632270 Fix selectbox on admin certification page 2024-06-01 17:29:45 +00:00
4709e095f8 Merge pull request 'Cold Storage with One-Way RSA Encryption' (#43) from coldstorage into main
Reviewed-on: #43
2024-05-31 00:01:31 +00:00
Noah Petherbridge
cc82fec108 Unit tests and code cleanup for cold storage 2024-05-30 16:59:21 -07:00
Noah Petherbridge
6f5127dd56 Cold Storage with One-Way RSA Encryption 2024-05-29 23:20:24 -07:00
Noah Petherbridge
2ac34eea79 Some CSS updates 2024-05-27 13:07:15 -07:00
Noah Petherbridge
5db1c03fd9 Clean up admin permission checks around the site 2024-05-27 13:02:05 -07:00
Noah Petherbridge
e71ca1fba3 Update wording re: secondary photo ID verification 2024-05-27 12:44:30 -07:00
Noah Petherbridge
97291c8721 Update documentation 2024-05-26 14:21:01 -07:00
Noah Petherbridge
f0e69f78da Certification: Secondary Photo ID Workflow
* Add an Admin Certification Photo workflow where we can request the user to
  upload a secondary form of ID (government issued photo ID showing their
  face and date of birth).
* An admin rejection option can request secondary photo ID.
* It sends a distinct e-mail to the user apart from the regular rejection email
* It flags their cert photo as "Secondary Needed" forever: even if the user
  removes their cert photo and starts from scratch, it will immediately request
  secondary ID when uploading a new primary photo.
* Secondary photos are deleted from the server on both Approve and Reject by
  the admin account, for user privacy.
* If approved, a Secondary Approved=true boolean is stored in the database. This
  boolean is set to False if the user deletes their cert photo in the future.
2024-05-26 12:34:00 -07:00
Noah Petherbridge
af76c251c6 Cloudflare CAPTCHA for account signup page 2024-05-20 13:29:02 -07:00
Noah Petherbridge
8ed489c264 iOS playsinline attribute for video tags
Prevent videos from automatically full-screening on autoplay by using
the iOS 10+ playsinline attribute.
2024-05-20 09:31:16 -07:00
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
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
2374 changed files with 419119 additions and 12670 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
/nonshy
/web/static/photos
/coldstorage
database.sqlite
settings.json
pgdump.sql

140
README.md
View File

@ -20,33 +20,6 @@ The website can also run out of a local SQLite database which is convenient
for local development. The production server runs on PostgreSQL and the
web app is primarily designed for that.
### PostGIS Extension for PostgreSQL
For the "Who's Nearby" feature to work you will need a PostgreSQL
database with the PostGIS geospatial extension installed. Usually
it might be a matter of `dnf install postgis` and activating the
extension on your nonshy database as your superuser (postgres):
```psql
create extension postgis;
```
If you get errors like "Type geography not found" from Postgres when
running distance based searches, this is the likely culprit.
### Face Detection
Fedora: `dnf install python3 python3-opencv opencv-data`
Debian: `apt install python3-opencv opencv-data`
If you get an error like:
> facedetect: error: cannot load HAAR_FRONTALFACE_ALT2 from /usr/share/opencv/haarcascades/haarcascade_frontalface_alt2.xml
Check whether the correct path on disk is actually /usr/share/opencv4 instead of /usr/share/opencv.
One solution then is to symlink the path correctly.
## Building the App
This app is written in Go: [go.dev](https://go.dev). You can probably
@ -74,6 +47,96 @@ a database.
For simple local development, just set `"UseSQLite": true` and the
app will run with a SQLite database.
### Postgres is Highly Recommended
This website is intended to run under PostgreSQL and some of its
features leverage Postgres specific extensions. For quick local
development, SQLite will work fine but some website features will
be disabled and error messages given. These include:
* Location features such as "Who's Nearby" (PostGIS extension)
* "Newest" tab on the forums: to deduplicate comments by most recent
thread depends on Postgres, SQLite will always show all latest
comments without deduplication.
### PostGIS Extension for PostgreSQL
For the "Who's Nearby" feature to work you will need a PostgreSQL
database with the PostGIS geospatial extension installed. Usually
it might be a matter of `dnf install postgis` and activating the
extension on your nonshy database as your superuser (postgres):
```psql
create extension postgis;
```
If you get errors like "Type geography not found" from Postgres when
running distance based searches, this is the likely culprit.
### Signed Photo URLs (NGINX)
The website supports "signed photo" URLs that can help protect the direct
links to user photos (their /static/photos/*.jpg paths) to ensure only
logged-in and authorized users are able to access those links.
This feature is not enabled (enforcing) by default, as it relies on
cooperation with the NGINX reverse proxy server
(module ngx_http_auth_request).
In your NGINX config, set your /static/ path to leverage NGINX auth_request
like so:
```nginx
server {
# your boilerplate server info (SSL, etc.) - not relevant to this example.
listen 80 default_server;
listen [::]:80 default_server;
# Relevant: setting the /static/ URL on NGINX to be an alias to your local
# nonshy static folder on disk. In this example, the git clone for the
# website was at /home/www-user/git/nonshy/website, so that ./web/static/
# is the local path where static files (e.g., photos) are uploaded.
location /static/ {
# Important: auth_request tells NGINX to do subrequest authentication
# on requests into the /static/ URI of your website.
auth_request /static-auth;
# standard NGINX alias commands.
alias /home/www-user/git/nonshy/website/web/static/;
autoindex off;
}
# Configure the internal subrequest auth path.
# Note: the path "/static-auth" can be anything you want.
location = /static-auth {
internal; # this is an internal route for NGINX only, not public
# Proxy to the /v1/auth/static URL on the web app.
# This line assumes the website runs on localhost:8080.
proxy_pass http://localhost:8080/v1/auth/static;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
# Important: the X-Original-URI header tells the web app what the
# original path (e.g. /static/photos/*) was, so the web app knows
# which sub-URL to enforce authentication on.
proxy_set_header X-Original-URI $request_uri;
}
}
```
When your NGINX config is set up like the above, you can edit the
settings.json to mark SignedPhoto/Enabled=true, and restart the
website. Be sure to test it!
On a photo gallery view, all image URLs under /static/photos/ should
come with a ?jwt= parameter, and the image should load for the current
user. The JWT token is valid for 30 seconds after which the direct link
to the image should expire and give a 403 Forbidden response.
When this feature is NOT enabled/not enforcing: the jwt= parameter is
still generated on photo URLs but is not enforced by the web app.
## Usage
The `nonshy` binary has sub-commands to either run the web server
@ -139,26 +202,15 @@ the web app by using the admin controls on their profile page.
templates, issue redirects, error pages, ...
* `pkg/utility`: miscellaneous useful functions for the app.
## Cron API Endpoints
## Cron workers
In settings.json get or configure the CronAPIKey (a UUID4 value is good and
the app generates a fresh one by default). The following are the cron API
endpoints that you may want to configure to run periodic maintenance tasks
on the app, such as to remove orphaned comment photos.
### GET /v1/comment-photos/remove-orphaned
Query parameters: `apiKey` which is the CronAPIKey.
This endpoint removes orphaned CommentPhotos (photo attachments to forum
posts). An orphaned photo is one that has no CommentID and was uploaded
older than 24 hours ago; e.g. a user uploaded a picture but then did not
complete the posting of their comment.
Suggested crontab:
You can schedule the `nonshy vacuum` command in your crontab. This command
will check and clean up the database for things such as: orphaned comment
photos (where somebody uploaded a photo to post on the forum, but then didn't
finish creating their post).
```cron
0 2 * * * curl "http://localhost:8080/v1/comment-photos/remove-orphaned?apiKey=X"
0 2 * * * cd /home/nonshy/git/website && ./nonshy vacuum
```
## License

View File

@ -6,11 +6,11 @@ import (
nonshy "code.nonshy.com/nonshy/website/pkg"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/encryption/coldstorage"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/models/backfill"
"code.nonshy.com/nonshy/website/pkg/models/exporting"
"code.nonshy.com/nonshy/website/pkg/photo"
"code.nonshy.com/nonshy/website/pkg/redis"
"code.nonshy.com/nonshy/website/pkg/worker"
"github.com/urfave/cli/v2"
@ -170,6 +170,81 @@ func main() {
},
},
},
{
Name: "coldstorage",
Usage: "cold storage functions for sensitive files",
Subcommands: []*cli.Command{
{
Name: "decrypt",
Usage: "decrypt a file from cold storage using the RSA private key",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "key",
Aliases: []string{"k"},
Required: true,
Usage: "RSA private key file for cold storage",
},
&cli.StringFlag{
Name: "aes",
Aliases: []string{"a"},
Required: true,
Usage: "AES key file used with the encrypted item in question (.aes file)",
},
&cli.StringFlag{
Name: "input",
Aliases: []string{"i"},
Required: true,
Usage: "input file to decrypt (.enc file)",
},
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Required: true,
Usage: "output file to write to (like a .jpg file)",
},
},
Action: func(c *cli.Context) error {
err := coldstorage.FileFromColdStorage(
c.String("key"),
c.String("aes"),
c.String("input"),
c.String("output"),
)
if err != nil {
log.Error("Error decrypting from cold storage: %s", err)
return err
}
log.Info("Wrote decrypted file to: %s", c.String("output"))
return nil
},
},
},
},
{
Name: "setup",
Usage: "setup and data import functions for the website",
Subcommands: []*cli.Command{
{
Name: "locations",
Usage: "import the database of world city locations from simplemaps.com",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "input",
Aliases: []string{"i"},
Required: true,
Usage: "the input worldcities.csv from simplemaps, with required headers: id, city, lat, lng, country, iso2",
},
},
Action: func(c *cli.Context) error {
initdb(c)
filename := c.String("input")
return models.InitializeWorldCities(filename)
},
},
},
},
{
Name: "backfill",
Usage: "One-off maintenance tasks and data backfills for database migrations",
@ -189,6 +264,35 @@ func main() {
return nil
},
},
{
Name: "photo-counts",
Usage: "repopulate cached Likes and Comment counts on photos",
Action: func(c *cli.Context) error {
initdb(c)
log.Info("Running BackfillPhotoCounts()")
err := backfill.BackfillPhotoCounts()
if err != nil {
return err
}
return nil
},
},
},
},
{
Name: "vacuum",
Usage: "Run database maintenance tasks (clean up broken links, remove orphaned comment photos, etc.) for data consistency.",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "dryrun",
Usage: "don't actually delete anything",
},
},
Action: func(c *cli.Context) error {
initdb(c)
return worker.Vacuum(c.Bool("dryrun"))
},
},
},
@ -230,9 +334,6 @@ func initdb(c *cli.Context) {
// Auto-migrate the DB.
models.AutoMigrate()
// Initialize FaceScore face detection.
photo.InitFaceScore()
}
func initcache(c *cli.Context) {

70
docs/Cold Storage.md Normal file
View File

@ -0,0 +1,70 @@
# Cold Storage
One of the security features of the website is **cold storage** which implements a "one way" encryption process for archiving sensitive files on the site.
The first use case is to archive secondary photo IDs: if a user was requested to provide a scan of their government issued photo ID for approval, the site can archive the original copy to cold storage when approved in case of any future inquiry.
The cold storage feature works by encrypting the file using an RSA public key, and relies on the matching private key to be **removed** from the web server and kept offline; so in case of a hack or data breach, the key that can decrypt the cold storage files will **NOT** be kept on the same web server.
This document explains how this feature works and how to configure it.
## Initialization
When the server starts up and there is not a cold storage RSA key configured, the feature will be initialized by generating new RSA encryption keys:
* The directory `./coldstorage/keys` is created and the RSA keys will be written in files named **private.pem** and **public.pem**.
* The RSA public key is _also_ written into the **settings.json** file for the server, at the Encryption / ColdStorageRSAPublicKey property.
You should **move the keys OFF of your web server machine** and keep them safe for your bookkeeping. Notably, the `private.pem` key is the sensitive file that should be removed.
The app does not need either of these keys to remain on the server: the settings.json has a copy of the RSA public key which the app uses to create cold storage encrypted files.
### Admin Dashboard Warning
As a safety precaution: if the private.pem key remains on disk, a warning is shown at the top of the Admin Dashboard page of the website to remind you that the key should be removed and stored safely offline.
## Recovering from Cold Storage
Should you need to recover an encrypted file from cold storage, the `nonshy coldstorage decrypt` command built into the Go server binary has the function to decrypt the files.
Every item that is moved into cold storage generates two files: an encrypted AES key file (`.aes`) and the encrypted data file itself (with a `.enc` extension). For example, a "photo.jpg" might go into cold storage as two files: "photo.jpg.aes" and "photo.jpg.enc"
You will need the following three files to decrypt from cold storage:
1. The RSA private key file (private.pem)
2. The encrypted AES key file (.aes extension)
3. The encrypted cold storage data file (.enc extension)
The command to decrypt them is thus like:
```bash
# command example
nonshy coldstorage decrypt \
--key private.pem \
--aes photo.jpg.aes \
--input photo.jpg.enc \
--output photo.jpg
# short command line flags work too
nonshy coldstorage decrypt -k private.pem \
-a photo.jpg.aes -i photo.jpg.enc \
-o photo.jpg
```
The `--output` file is where the decrypted file will be written to.
## Encryption Algorithms
When a file is moved into cold storage:
1. A fresh new AES symmetric key is generated from scratch.
2. The AES key is encrypted using the **RSA public key** and written to the ".aes" file in the coldstorage/ folder.
3. The original file is encrypted using that AES symmetric key and written to the ".enc" file in the coldstorage/ folder.
At the end of the encrypt function: the web server no longer has the AES key and is _unable_ to decrypt it because the private key is not available (as it should be kept offline for security).
Decrypting a file out of cold storage is done like so:
1. The encrypted AES key is unlocked using the **RSA private key**.
2. The encrypted cold storage file (.enc) is decrypted with that AES key.
3. The cleartext data is written to the output file.

84
go.mod
View File

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

314
go.sum
View File

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

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

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

View File

@ -0,0 +1,37 @@
package config
import "strings"
// Admin Labels.
const (
// Admin Labels for Photos
AdminLabelPhotoNonExplicit = "non-explicit"
AdminLabelPhotoForceExplicit = "force-explicit"
)
var (
AdminLabelPhotoOptions = []ChecklistOption{
{
Value: AdminLabelPhotoNonExplicit,
Label: "This is not an Explicit photo",
Help: "Hide the prompt 'Should this photo be marked as explicit?' as this photo does not NEED to be Explicit. " +
"Note: the owner of this photo MAY still mark it explicit if they want to.",
},
{
Value: AdminLabelPhotoForceExplicit,
Label: "Force this photo to be marked as Explicit",
Help: "Enabling this option will force the Explicit tag to stay on, and not allow the user to remove it.",
},
}
)
// HasAdminLabel checks if a comma-separated set of admin labels contains the label.
func HasAdminLabel(needle string, haystack string) bool {
labels := strings.Split(haystack, ",")
for _, label := range labels {
if strings.TrimSpace(label) == needle {
return true
}
}
return false
}

View File

@ -6,11 +6,9 @@ const (
// - Chat: have operator controls in the chat room
// - Forum: ability to edit and delete user posts
// - Photo: omniscient view of all gallery photos, can edit/delete photos
// - Inner circle: ability to remove users from it
ScopeChatModerator = "social.moderator.chat"
ScopeForumModerator = "social.moderator.forum"
ScopePhotoModerator = "social.moderator.photo"
ScopeCircleModerator = "social.moderator.inner-circle"
ScopeChatModerator = "social.moderator.chat"
ScopeForumModerator = "social.moderator.forum"
ScopePhotoModerator = "social.moderator.photo"
// Certification photo management
// - Approve: ability to respond to pending certification pics
@ -32,21 +30,53 @@ const (
// - Impersonate: ability to log in as a user account
// - Ban: ability to ban/unban users
// - Delete: ability to delete user accounts
ScopeUserCreate = "admin.user.create"
ScopeUserInsight = "admin.user.insights"
ScopeUserImpersonate = "admin.user.impersonate"
ScopeUserBan = "admin.user.ban"
ScopeUserPromote = "admin.user.promote"
ScopeUserPassword = "admin.user.password"
ScopeUserDelete = "admin.user.delete"
ScopeUserPromote = "admin.user.promote"
// Other admin views
ScopeFeedbackAndReports = "admin.feedback"
ScopeChangeLog = "admin.changelog"
ScopeUserNotes = "admin.user.notes"
// Admins with this scope can not be blocked by users.
ScopeUnblockable = "admin.unblockable"
// Special scope to mark an admin automagically in the Inner Circle
ScopeIsInnerCircle = "admin.override.inner-circle"
// The global wildcard scope gets all available permissions.
ScopeSuperuser = "*"
)
// Friendly description for each scope.
var AdminScopeDescriptions = map[string]string{
ScopeChatModerator: "Have operator controls in the chat room (can mark cameras as explicit, or kick/ban people from chat).",
ScopeForumModerator: "Ability to moderate the forum (edit or delete posts).",
ScopePhotoModerator: "Ability to moderate photo galleries (can see all private or friends-only photos, and edit or delete them).",
ScopeCertificationApprove: "Ability to see pending certification pictures and approve or reject them.",
ScopeCertificationList: "Ability to see existing certification pictures that have already been approved or rejected.",
ScopeCertificationView: "Ability to see and double check a specific user's certification picture on demand.",
ScopeForumAdmin: "Ability to manage forums themselves (add or remove forums, edit their properties).",
ScopeAdminScopeAdmin: "Ability to manage admin permissions for other admin accounts.",
ScopeMaintenance: "Ability to activate maintenance mode functions of the website (turn features on or off, disable signups or logins, etc.)",
ScopeUserCreate: "Ability to manually create a new user account, bypassing the signup page.",
ScopeUserInsight: "Ability to see admin insights about a user profile (e.g. their block lists and who blocks them).",
ScopeUserImpersonate: "Ability to log in as any user account (note: this action is logged and notifies all admins when it happens. Admins must write a reason and it is used to diagnose customer support issues, help with their certification picture, or investigate a reported Direct Message conversation they had).",
ScopeUserBan: "Ability to ban or unban user accounts.",
ScopeUserPassword: "Ability to reset a user's password on their behalf.",
ScopeUserDelete: "Ability to fully delete user accounts on their behalf.",
ScopeUserPromote: "Ability to add or remove the admin status flag on a user profile.",
ScopeFeedbackAndReports: "Ability to see admin reports and user feedback.",
ScopeChangeLog: "Ability to see website change logs (e.g. history of a certification photo, gallery photo settings, etc.)",
ScopeUserNotes: "Ability to see all notes written about a user, or to see all notes written by admins.",
ScopeUnblockable: "This admin can not be added to user block lists.",
ScopeSuperuser: "This admin has access to ALL admin features on the website.",
}
// Number of expected scopes for unit test and validation.
const QuantityAdminScopes = 16
const QuantityAdminScopes = 20
// The specially named Superusers group.
const AdminGroupSuperusers = "Superusers"
@ -57,18 +87,26 @@ func ListAdminScopes() []string {
ScopeChatModerator,
ScopeForumModerator,
ScopePhotoModerator,
ScopeCircleModerator,
ScopeCertificationApprove,
ScopeCertificationList,
ScopeCertificationView,
ScopeForumAdmin,
ScopeAdminScopeAdmin,
ScopeMaintenance,
ScopeUserCreate,
ScopeUserInsight,
ScopeUserImpersonate,
ScopeUserBan,
ScopeUserPassword,
ScopeUserDelete,
ScopeUserPromote,
ScopeFeedbackAndReports,
ScopeChangeLog,
ScopeUserNotes,
ScopeUnblockable,
ScopeIsInnerCircle,
}
}
func AdminScopeDescription(scope string) string {
return AdminScopeDescriptions[scope]
}

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

@ -25,6 +25,10 @@ const (
PhotoDiskPath = "./web/static/photos"
)
// PhotoURLRegexp describes an image path under "/static/photos" that can be parsed from Markdown or HTML input.
// It is used by e.g. the ReSignURLs function - if you move image URLs to a CDN this may need updating.
var PhotoURLRegexp = regexp.MustCompile(`(?:['"])(/static/photos/[^'"\s?]+(?:\?[^'"\s]*)?)(?:['"]|[^'"\s]*)`)
// Security
const (
BcryptCost = 14
@ -38,6 +42,11 @@ const (
TwoFactorBackupCodeCount = 12
TwoFactorBackupCodeLength = 8 // characters a-z0-9
// Signed URLs for static photo authentication.
SignedPhotoJWTExpires = 30 * time.Second // Regular, per-user, short window
SignedPublicAvatarJWTExpires = 7 * 24 * time.Hour // Widely public, e.g. chat room
SignedPublicAvatarUsername = "@" // JWT 'username' for widely public JWT
)
// Authentication
@ -51,6 +60,11 @@ const (
ChangeEmailRedisKey = "change-email/%s"
SignupTokenExpires = 24 * time.Hour // used for all tokens so far
// How to rate limit same types of emails being delivered, e.g.
// signups, cert approvals (double post), etc.
EmailDebounceDefault = 24 * time.Hour // default debounce per type of email
EmailDebounceResetPassword = 4 * time.Hour // "forgot password" emails debounce
// Rate limits
RateLimitRedisKey = "rate-limit/%s/%s" // namespace, id
LoginRateLimitWindow = 1 * time.Hour
@ -66,15 +80,25 @@ const (
ContactRateLimitCooldownAt = 1
ContactRateLimitCooldown = 2 * time.Minute
// "Mark Explicit" rate limit to curb a mischievous user just bulk marking the
// whole gallery as explicit.
MarkExplicitRateLimitWindow = 1 * time.Hour
MarkExplicitRateLimit = 20 // 10 failed MarkExplicit attempts = locked for full hour
MarkExplicitRateLimitCooldownAt = 10 // 10 photos in an hour, start throttling.
MarkExplicitRateLimitCooldown = time.Minute
// How frequently to refresh LastLoginAt since sessions are long-lived.
LastLoginAtCooldown = time.Hour
// Chat room status refresh interval.
ChatStatusRefreshInterval = 30 * time.Second
// Cache TTL for the demographics page.
DemographicsCacheTTL = time.Hour
)
var (
UsernameRegexp = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`)
UsernameRegexp = regexp.MustCompile(`^[a-z0-9_.-]{3,32}$`)
ReservedUsernames = []string{
"admin",
"admins",
@ -94,20 +118,24 @@ var (
const (
MaxPhotoWidth = 1280
ProfilePhotoWidth = 512
AltTextMaxLength = 5000
// Quotas for uploaded photos.
PhotoQuotaUncertified = 6
PhotoQuotaCertified = 100
// Min number of public photos for inner circle members to see the prompt to invite.
InnerCircleMinimumPublicPhotos = 5
// Rate limit for too many Site Gallery pictures.
// Some users sign up and immediately max out their gallery and spam
// the Site Gallery page. These limits can ensure only a few Site Gallery
// pictures can be posted per day.
SiteGalleryRateLimitMax = 5
SiteGalleryRateLimitInterval = 24 * time.Hour
// Only ++ the Views count per user per photo within a small
// window of time - if a user keeps reloading the same photo
// rapidly it does not increment the view counter more.
PhotoViewDebounceRedisKey = "debounce-view/user=%d/photoid=%d"
PhotoViewDebounceCooldown = 1 * time.Hour
)
// Forum settings
@ -117,6 +145,27 @@ const (
// rapidly it does not increment the view counter more.
ThreadViewDebounceRedisKey = "debounce-view/user=%d/thr=%d"
ThreadViewDebounceCooldown = 1 * time.Hour
// Enable user-owned forums (feature flag)
UserForumsEnabled = true
)
// User-Owned Forums: Quota settings for how many forums a user can own.
var (
// They get one forum after they've been Certified for 45 days.
UserForumQuotaCertLifetimeDays = time.Hour * 24 * 45
// Schedule for gaining additional quota for a number of comments written
// on any forum thread. The user must have the sum of all of these post
// counts to gain one forum per level.
UserForumQuotaCommentCountSchedule = []int64{
10, // Get a forum after your first 10 posts.
20, // Get a 2nd forum after 20 additional posts (30 total)
30, // 30 more posts (60 total)
60, // 60 more posts (120 total)
80, // 80 more posts (200 total)
100, // and then one new forum for every 100 additional posts
}
)
// Poll settings

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{
@ -64,12 +68,18 @@ var (
"music_movies",
"hide_age",
}
EssayProfileFields = []string{
"about_me",
"interests",
"music_movies",
}
// Site preference names (stored in ProfileField table)
SitePreferenceFields = []string{
"dm_privacy",
"blur_explicit",
"site_gallery_default", // default view on site gallery (friends-only or all certified?)
"chat_moderation_rules",
}
// Choices for the Contact Us subject
@ -90,6 +100,8 @@ var (
{"report.photo", "Report a problematic photo"},
{"report.message", "Report a direct message conversation"},
{"report.comment", "Report a forum post or comment"},
{"report.forum", "Report a forum or community"},
{"forum.adopt", "Adopt a forum"},
},
},
}
@ -97,12 +109,41 @@ var (
// Default forum categories for forum landing page.
ForumCategories = []string{
"Rules and Announcements",
"The Inner Circle",
"Nudists",
"Exhibitionists",
"Photo Boards",
"Anything Goes",
}
// Keywords that appear in a DM that make it likely spam.
DirectMessageSpamKeywords = []*regexp.Regexp{
regexp.MustCompile(`\b(telegram|whats\s*app|signal|kik|session)\b`),
regexp.MustCompile(`https?://(t.me|join.skype.com|zoom.us|whereby.com|meet.jit.si|wa.me)`),
}
// Chat Moderation Rules.
ChatModerationRules = []ChecklistOption{
{
Value: "redcam",
Label: "Red camera",
Help: "The user's camera is forced to 'explicit' when they are broadcasting.",
},
{
Value: "nobroadcast",
Label: "No broadcast",
Help: "The user can not broadcast their webcam, but may still watch other peoples' webcams.",
},
{
Value: "novideo",
Label: "No webcam privileges ('Shy Accounts')",
Help: "The user can not broadcast or watch any webcam. Note: this option supercedes all other video-related rules.",
},
{
Value: "noimage",
Label: "No image sharing privileges ('Shy Accounts')",
Help: "The user can not share or see any image shared on chat.",
},
}
)
// ContactUs choices for the subject drop-down.
@ -117,6 +158,13 @@ type Option struct {
Label string
}
// ChecklistOption for checkbox-lists.
type ChecklistOption struct {
Value string
Label string
Help string
}
// NotificationOptout field values (stored in user ProfileField table)
const (
NotificationOptOutFriendPhotos = "notif_optout_friends_photos"
@ -127,6 +175,10 @@ const (
NotificationOptOutSubscriptions = "notif_optout_subscriptions"
NotificationOptOutFriendRequestAccepted = "notif_optout_friend_request_accepted"
NotificationOptOutPrivateGrant = "notif_optout_private_grant"
// Web Push Notifications
PushNotificationOptOutMessage = "notif_optout_push_messages"
PushNotificationOptOutFriends = "notif_optout_push_friends"
)
// Notification opt-outs (stored in ProfileField table)
@ -140,3 +192,9 @@ var NotificationOptOutFields = []string{
NotificationOptOutFriendRequestAccepted,
NotificationOptOutPrivateGrant,
}
// Push Notification opt-outs (stored in ProfileField table)
var PushNotificationOptOutFields = []string{
PushNotificationOptOutMessage,
PushNotificationOptOutFriends,
}

View File

@ -15,13 +15,16 @@ var (
PageSizePrivatePhotoGrantees = 12
PageSizeAdminCertification = 20
PageSizeAdminFeedback = 20
PageSizeAdminFeedbackNotesPage = 5 // feedback on User Notes page
PageSizeAdminFeedbackNotesPage = 5 // feedback on User Notes page
PageSizeChangeLog = 20
PageSizeAdminUserNotes = 10 // other users' notes
PageSizeSiteGallery = 16
PageSizeUserGallery = 16
PageSizeInboxList = 20 // sidebar list
PageSizeInboxThread = 10 // conversation view
PageSizeInboxList = 20 // sidebar list
PageSizeInboxThread = 10 // conversation view
PageSizeBrowseForums = 20
PageSizeForums = 100 // TODO: for main category index view
PageSizeMyListForums = 20 // "My List" pager on forum home (categories) page.
PageSizeThreadList = 20 // 20 threads per board, 20 posts per thread
PageSizeForumAdmin = 20
PageSizeDashboardNotifications = 50

View File

@ -4,17 +4,18 @@ import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"code.nonshy.com/nonshy/website/pkg/encryption/coldstorage"
"code.nonshy.com/nonshy/website/pkg/encryption/keygen"
"code.nonshy.com/nonshy/website/pkg/log"
"github.com/SherClockHolmes/webpush-go"
"github.com/google/uuid"
)
// Version of the config format - when new fields are added, it will attempt
// to write the settings.toml to disk so new defaults populate.
var currentVersion = 3
var currentVersion = 5
// Current loaded settings.json
var Current = DefaultVariable()
@ -31,7 +32,9 @@ type Variable struct {
BareRTC BareRTC
Maintenance Maintenance
Encryption Encryption
FaceScore FaceScore
SignedPhoto SignedPhoto
WebPush WebPush
Turnstile Turnstile
UseXForwardedFor bool
}
@ -53,9 +56,6 @@ func DefaultVariable() Variable {
SQLite: "database.sqlite",
Postgres: "host=localhost user=nonshy password=nonshy dbname=nonshy port=5679 sslmode=disable TimeZone=America/Los_Angeles",
},
FaceScore: FaceScore{
CascadeFile: "/path/to/cascade/file",
},
CronAPIKey: uuid.New().String(),
}
}
@ -66,7 +66,7 @@ func LoadSettings() {
if _, err := os.Stat(SettingsPath); !os.IsNotExist(err) {
log.Info("Loading settings from %s", SettingsPath)
content, err := ioutil.ReadFile(SettingsPath)
content, err := os.ReadFile(SettingsPath)
if err != nil {
panic(fmt.Sprintf("LoadSettings: couldn't read settings.json: %s", err))
}
@ -101,6 +101,38 @@ func LoadSettings() {
writeSettings = true
}
// Initialize the cold storage RSA keys.
if len(Current.Encryption.ColdStorageRSAPublicKey) == 0 {
x509publicKey, err := coldstorage.Initialize()
if err != nil {
log.Error("Initializing cold storage: %s", err)
os.Exit(1)
}
// Store the public key in the settings.json.
Current.Encryption.ColdStorageRSAPublicKey = x509publicKey
writeSettings = true
}
// Initialize the VAPID keys for Web Push Notification.
if len(Current.WebPush.VAPIDPublicKey) == 0 {
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
if err != nil {
log.Error("Initializing VAPID keys for Web Push: %s", err)
os.Exit(1)
}
Current.WebPush.VAPIDPrivateKey = privateKey
Current.WebPush.VAPIDPublicKey = publicKey
writeSettings = true
}
// Initialize JWT token for SignedPhoto feature.
if Current.SignedPhoto.JWTSecret == "" {
Current.SignedPhoto.JWTSecret = uuid.New().String()
writeSettings = true
}
// Have we added new config fields? Save the settings.json.
if Current.Version != currentVersion || writeSettings {
log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.")
@ -123,7 +155,7 @@ func WriteSettings() error {
panic(fmt.Sprintf("WriteSettings: couldn't marshal settings: %s", err))
}
return ioutil.WriteFile(SettingsPath, buf.Bytes(), 0600)
return os.WriteFile(SettingsPath, buf.Bytes(), 0600)
}
// Mail settings.
@ -167,11 +199,25 @@ type Maintenance struct {
// Encryption settings.
type Encryption struct {
AESKey []byte
AESKey []byte
ColdStorageRSAPublicKey []byte
}
// FaceScore settings for face detection in photos via esimov/pigo.
type FaceScore struct {
Enabled bool
CascadeFile string
// SignedPhoto settings.
type SignedPhoto struct {
Enabled bool
JWTSecret string
}
// WebPush settings.
type WebPush struct {
VAPIDPublicKey string
VAPIDPrivateKey string
}
// Turnstile (Cloudflare CAPTCHA) settings.
type Turnstile struct {
Enabled bool
SiteKey string
SecretKey string
}

View File

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

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

@ -1,168 +0,0 @@
package account
import (
"fmt"
"net/http"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// InnerCircle is the landing page for inner circle members only.
func InnerCircle() http.HandlerFunc {
tmpl := templates.Must("account/inner_circle.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, err := session.CurrentUser(r)
if err != nil || !currentUser.IsInnerCircle() {
templates.NotFoundPage(w, r)
return
}
var vars = map[string]interface{}{
"InnerCircleMinimumPublicPhotos": config.InnerCircleMinimumPublicPhotos,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
// InviteCircle is the landing page to invite a user into the circle.
func InviteCircle() http.HandlerFunc {
tmpl := templates.Must("account/invite_circle.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, err := session.CurrentUser(r)
if err != nil || !currentUser.IsInnerCircle() {
templates.NotFoundPage(w, r)
return
}
// Invite whom?
username := r.FormValue("to")
user, err := models.FindUser(username)
if err != nil {
templates.NotFoundPage(w, r)
return
}
if currentUser.ID == user.ID && currentUser.InnerCircle {
session.FlashError(w, r, "You are already part of the inner circle.")
templates.Redirect(w, "/inner-circle")
return
}
// Any blocking?
if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
session.FlashError(w, r, "You are blocked from inviting this user to the circle.")
templates.Redirect(w, "/inner-circle")
return
}
// POSTing?
if r.Method == http.MethodPost {
var (
confirm = r.FormValue("intent") == "confirm"
)
if !confirm {
templates.Redirect(w, "/u/"+username)
return
}
// Add them!
if err := models.AddToInnerCircle(user); err != nil {
session.FlashError(w, r, "Couldn't add to the inner circle: %s", err)
} else {
session.Flash(w, r, "%s has been added to the inner circle!", user.Username)
}
log.Info("InnerCircle: %s adds %s to the inner circle", currentUser.Username, user.Username)
templates.Redirect(w, "/photo/u/"+user.Username)
return
}
var vars = map[string]interface{}{
"User": user,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
// RemoveCircle is the admin-only page to remove a member from the circle.
func RemoveCircle() http.HandlerFunc {
tmpl := templates.Must("account/remove_circle.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, err := session.CurrentUser(r)
if err != nil || !currentUser.IsInnerCircle() {
templates.NotFoundPage(w, r)
return
}
// Remove whom?
username := r.FormValue("to")
user, err := models.FindUser(username)
if err != nil {
templates.NotFoundPage(w, r)
return
}
// POSTing?
if r.Method == http.MethodPost {
var (
confirm = r.FormValue("intent") == "confirm"
)
if !confirm {
templates.Redirect(w, "/u/"+username)
return
}
// Admin (with the correct scope): remove them now.
if currentUser.HasAdminScope(config.ScopeCircleModerator) {
if err := models.RemoveFromInnerCircle(user); err != nil {
session.FlashError(w, r, "Couldn't remove from the inner circle: %s", err)
}
session.Flash(w, r, "%s has been removed from the inner circle!", user.Username)
} else {
// Non-admin user: request removal only.
fb := &models.Feedback{
Intent: "report.circle",
Subject: "Inner Circle Removal Request",
TableName: "users",
TableID: user.ID,
Message: fmt.Sprintf(
"An inner circle member has flagged that **%s** no longer qualifies to be a part of the inner circle and should be removed.",
user.Username,
),
}
if err := models.CreateFeedback(fb); err != nil {
session.FlashError(w, r, "Couldn't create admin notification: %s", err)
} else {
session.Flash(w, r, "A request to remove %s from the inner circle has been sent to the site admin.", user.Username)
}
}
templates.Redirect(w, "/u/"+user.Username)
return
}
var vars = map[string]interface{}{
"User": user,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -38,10 +38,23 @@ func Login() http.HandlerFunc {
CooldownAt: config.LoginRateLimitCooldownAt,
Cooldown: config.LoginRateLimitCooldown,
}
var takebackDeferredError bool
if err := limiter.Ping(); err != nil {
session.FlashError(w, r, err.Error())
templates.Redirect(w, r.URL.Path)
return
// Is it a deferred error? Flash it at the end of the request but continue
// to process this login attempt as normal.
if ratelimit.IsDeferredError(err) {
defer func() {
if takebackDeferredError {
return
}
session.FlashError(w, r, err.Error())
}()
} else {
// Lock-out error, show it now and quit.
session.FlashError(w, r, err.Error())
templates.Redirect(w, r.URL.Path)
return
}
}
// Look up their account.
@ -124,6 +137,9 @@ func Login() http.HandlerFunc {
log.Error("Failed to clear login rate limiter: %s", err)
}
// If there was going to be a deferred ratelimit error, take it back.
takebackDeferredError = true
// Redirect to their dashboard.
session.Flash(w, r, "Login successful.")
if strings.HasPrefix(next, "/") {

View File

@ -3,8 +3,9 @@ package account
import (
"net/http"
"net/url"
"regexp"
"strings"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/middleware"
"code.nonshy.com/nonshy/website/pkg/models"
@ -13,18 +14,12 @@ import (
"code.nonshy.com/nonshy/website/pkg/worker"
)
var ProfileRegexp = regexp.MustCompile(`^/u/([^@]+?)$`)
// User profile page (/u/username)
func Profile() http.HandlerFunc {
tmpl := templates.Must("account/profile.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the username out of the URL parameters.
var username string
m := ProfileRegexp.FindStringSubmatch(r.URL.Path)
if m != nil {
username = m[1]
}
var username = r.PathValue("username")
// Find this user.
user, err := models.FindUser(username)
@ -45,7 +40,8 @@ func Profile() http.HandlerFunc {
}
vars := map[string]interface{}{
"User": user,
"User": user,
"IsExternalView": true,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@ -77,23 +73,18 @@ func Profile() http.HandlerFunc {
// Inject relationship booleans for profile picture display.
models.SetUserRelationships(currentUser, []*models.User{user})
// Admin user can always see the profile pic - but only on this page. Other avatar displays
// will show the yellow or pink shy.png if the admin is not friends or not granted.
if currentUser.IsAdmin {
// Admin user (photo moderator) can always see the profile pic - but only on this page.
// Other avatar displays will show the yellow or pink shy.png if the admin is not friends or not granted.
if currentUser.HasAdminScope(config.ScopePhotoModerator) {
user.UserRelationship.IsFriend = true
user.UserRelationship.IsPrivateGranted = true
}
var isSelf = currentUser.ID == user.ID
// Banned or disabled? Only admin can view then.
if user.Status != models.UserStatusActive && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
return
}
// Is either one blocking?
if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
// Give a Not Found page if we can not see this user.
if err := user.CanBeSeenBy(currentUser); err != nil {
log.Error("%s can not be seen by viewer %s: %s", user.Username, currentUser.Username, err)
templates.NotFoundPage(w, r)
return
}
@ -113,27 +104,32 @@ func Profile() http.HandlerFunc {
log.Error("WhoLikes(user %d): %s", user.ID, err)
}
// Chat Moderation Rule: count of rules applied to the user, for admin view.
var chatModerationRules int
if currentUser.HasAdminScope(config.ScopeChatModerator) {
if rules := user.GetProfileField("chat_moderation_rules"); len(rules) > 0 {
chatModerationRules = len(strings.Split(rules, ","))
}
}
vars := map[string]interface{}{
"User": user,
"LikeMap": likeMap,
"IsFriend": isFriend,
"IsPrivate": isPrivate,
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
"NoteCount": models.CountNotesAboutUser(currentUser, user),
"FriendCount": models.CountFriends(user.ID),
"ForumThreadCount": models.CountThreadsByUser(user),
"ForumReplyCount": models.CountCommentsByUser(user, "threads"),
"PhotoCommentCount": models.CountCommentsByUser(user, "photos"),
"CommentsReceivedCount": models.CountCommentsReceived(user),
"LikesGivenCount": models.CountLikesGiven(user),
"LikesReceivedCount": models.CountLikesReceived(user),
"OnChat": worker.GetChatStatistics().IsOnline(user.Username),
"User": user,
"LikeMap": likeMap,
"IsFriend": isFriend,
"IsPrivate": isPrivate,
"PhotoCount": models.CountPhotosICanSee(user, currentUser),
"NoteCount": models.CountNotesAboutUser(currentUser, user),
"FriendCount": models.CountFriends(user.ID),
"OnChat": worker.GetChatStatistics().IsOnline(user.Username),
// Details on who likes their profile page.
"LikeExample": likeExample,
"LikeRemainder": likeRemainder,
"LikeTableName": "users",
"LikeTableID": user.ID,
// Admin numbers.
"NumChatModerationRules": chatModerationRules,
}
if err := tmpl.Execute(w, r, vars); err != nil {

View File

@ -135,17 +135,25 @@ func ForgotPassword() http.HandlerFunc {
return
}
// Email them their reset link.
if err := mail.Send(mail.Message{
To: user.Email,
Subject: "Reset your forgotten password",
Template: "email/reset_password.html",
Data: map[string]interface{}{
"Username": user.Username,
"URL": config.Current.BaseURL + "/forgot-password?token=" + token.Token,
},
}); err != nil {
session.FlashError(w, r, "Error sending an email: %s", err)
// Email them their reset link -- if not banned.
if !user.IsBanned() {
if err := mail.LockSending("reset_password", user.Email, config.EmailDebounceResetPassword); err == nil {
if err := mail.Send(mail.Message{
To: user.Email,
Subject: "Reset your forgotten password",
Template: "email/reset_password.html",
Data: map[string]interface{}{
"Username": user.Username,
"URL": config.Current.BaseURL + "/forgot-password?token=" + token.Token,
},
}); err != nil {
session.FlashError(w, r, "Error sending an email: %s", err)
}
} else {
log.Error("LockSending: reset_password e-mail is not sent to %s: one was sent recently", user.Email)
}
} else {
log.Error("Do not send 'forgot password' e-mail to %s: user is banned", user.Email)
}
// Success message and redirect away.

View File

@ -1,14 +1,17 @@
package account
import (
"fmt"
"net/http"
"strconv"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/controller/chat"
"code.nonshy.com/nonshy/website/pkg/geoip"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/spam"
"code.nonshy.com/nonshy/website/pkg/templates"
"code.nonshy.com/nonshy/website/pkg/worker"
)
@ -21,6 +24,7 @@ func Search() http.HandlerFunc {
var sortWhitelist = []string{
"last_login_at desc",
"created_at desc",
"certified_at desc",
"username",
"username desc",
"lower(name)",
@ -32,12 +36,16 @@ func Search() http.HandlerFunc {
// Search filters.
var (
isCertified = r.FormValue("certified")
username = r.FormValue("username") // username search
username = r.FormValue("name") // username search
searchTerm = r.FormValue("search") // profile text search
citySearch = r.FormValue("wcs")
gender = r.FormValue("gender")
orientation = r.FormValue("orientation")
maritalStatus = r.FormValue("marital_status")
hereFor = r.FormValue("here_for")
friendSearch = r.FormValue("friends") == "true"
likedSearch = r.FormValue("liked") == "true"
onChatSearch = r.FormValue("on_chat") == "true"
sort = r.FormValue("sort")
sortOK bool
)
@ -48,6 +56,9 @@ func Search() http.HandlerFunc {
ageMin, ageMax = ageMax, ageMin
}
rawSearch := models.ParseSearchString(searchTerm)
search, restricted := spam.RestrictSearchTerms(rawSearch)
// Get current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
@ -56,6 +67,32 @@ func Search() http.HandlerFunc {
return
}
// Report when search terms are restricted.
if restricted != nil {
// Admin users: allow the search anyway.
if currentUser.IsAdmin {
search = rawSearch
} else {
fb := &models.Feedback{
Intent: "report",
Subject: "Search Keyword Blacklist",
UserID: currentUser.ID,
TableName: "users",
TableID: currentUser.ID,
Message: fmt.Sprintf(
"A user has run a search on the Member Directory using search terms which are prohibited.\n\n"+
"Their search query was: %s",
searchTerm,
),
}
// Save the feedback.
if err := models.CreateFeedback(fb); err != nil {
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
}
}
}
// Geolocation/Who's Nearby: if the current user uses GeoIP, update
// their coordinates now.
myLocation, err := models.RefreshGeoIP(currentUser.ID, r)
@ -63,6 +100,24 @@ func Search() http.HandlerFunc {
log.Error("RefreshGeoIP: %s", err)
}
// Are they doing a Location search (from world city typeahead)?
var city *models.WorldCities
if citySearch != "" {
sort = "distance"
// Require the current user to have THEIR location set, for fairness.
if myLocation.Source == models.LocationSourceNone {
session.FlashError(w, r, "You must set your own location before you can search for others by their location.")
} else {
// Look up the coordinates of their search.
city, err = models.FindWorldCity(citySearch)
if err != nil {
session.FlashError(w, r, "Location search: no match was found for '%s', please use one of the exact search results from the type-ahead on the Location field.", citySearch)
citySearch = "" // null out their search
}
}
}
// Sort options.
for _, v := range sortWhitelist {
if sort == v {
@ -74,11 +129,39 @@ func Search() http.HandlerFunc {
sort = "last_login_at desc"
}
// Real name for certified_at
if sort == "certified_at desc" {
sort = "certification_photos.updated_at desc"
}
// Default
if isCertified == "" {
isCertified = "true"
}
// Always filter for certified-only users unless the request specifically looked for non-certified.
// Searches for disabled/banned users (admin only) should also reveal ALL users including non-certified.
var certifiedOnly = true
if isCertified == "false" || isCertified == "all" || isCertified == "disabled" || isCertified == "banned" {
certifiedOnly = false
}
// Non-admin view: always hide non-certified profiles, they can be unsafe (fake profiles, scams if they won't certify)
if !currentUser.IsAdmin {
certifiedOnly = true
}
// Are we filtering for "On Chat?"
var inUsername = []string{}
if onChatSearch {
stats := chat.FilteredChatStatistics(currentUser)
inUsername = stats.Usernames
if len(inUsername) == 0 {
session.FlashError(w, r, "Notice: you wanted to filter by people currently on the chat room, but nobody is on chat at this time.")
inUsername = []string{"@"}
}
}
pager := &models.Pagination{
PerPage: config.PageSizeMemberSearch,
Sort: sort,
@ -87,21 +170,26 @@ func Search() http.HandlerFunc {
users, err := models.SearchUsers(currentUser, &models.UserSearch{
Username: username,
InUsername: inUsername,
Gender: gender,
Orientation: orientation,
MaritalStatus: maritalStatus,
HereFor: hereFor,
Certified: isCertified == "true",
ProfileText: search,
Certified: certifiedOnly,
NearCity: city,
NotCertified: isCertified == "false",
InnerCircle: isCertified == "circle",
ShyAccounts: isCertified == "shy",
IsBanned: isCertified == "banned",
IsDisabled: isCertified == "disabled",
IsAdmin: isCertified == "admin",
Friends: friendSearch,
Liked: likedSearch,
AgeMin: ageMin,
AgeMax: ageMax,
}, pager)
if err != nil {
session.FlashError(w, r, "Couldn't search users: %s", err)
session.FlashError(w, r, "An error has occurred: %s.", err)
}
// Who's Nearby feature, get some data.
@ -109,8 +197,16 @@ func Search() http.HandlerFunc {
// Collect usernames to map to chat online status.
var usernames = []string{}
var userIDs = []uint64{}
for _, user := range users {
usernames = append(usernames, user.Username)
userIDs = append(userIDs, user.ID)
}
// User IDs of these I have "Liked"
likedIDs, err := models.LikedIDs(currentUser, "users", userIDs)
if err != nil {
log.Error("LikedIDs: %s", err)
}
var vars = map[string]interface{}{
@ -125,19 +221,27 @@ func Search() http.HandlerFunc {
"MaritalStatus": maritalStatus,
"HereFor": hereFor,
"EmailOrUsername": username,
"Search": searchTerm,
"City": citySearch,
"AgeMin": ageMin,
"AgeMax": ageMax,
"FriendSearch": friendSearch,
"LikedSearch": likedSearch,
"OnChatSearch": onChatSearch,
"Sort": sort,
// Restricted Search errors.
"RestrictedSearchError": restricted,
// Photo counts mapped to users
"PhotoCountMap": models.MapPhotoCounts(users),
// Map Shy Account badges for these results
"ShyMap": models.MapShyAccounts(users),
// Map friendships to these users.
// Map friendships and likes to these users.
"FriendMap": models.MapFriends(currentUser, users),
"LikedMap": models.MapLikes(currentUser, "users", likedIDs),
// Users on the chat room map.
"UserOnChatMap": worker.GetChatStatistics().MapUsersOnline(usernames),
@ -145,7 +249,7 @@ func Search() http.HandlerFunc {
// Current user's location setting.
"MyLocation": myLocation,
"GeoIPInsights": insights,
"DistanceMap": models.MapDistances(currentUser, users),
"DistanceMap": models.MapDistances(currentUser, city, users),
}
if err := tmpl.Execute(w, r, vars); err != nil {

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,11 +53,19 @@ func Settings() http.HandlerFunc {
return
}
// Is the user currently in the chat room? Gate username changes when so.
var isOnChat = worker.GetChatStatistics().IsOnline(user.Username)
vars["OnChat"] = isOnChat
// URL hashtag to redirect to
var hashtag string
// Are we POSTing?
if r.Method == http.MethodPost {
// Will they BECOME a Shy Account with this change?
var wasShy = user.IsShy()
intent := r.PostFormValue("intent")
switch intent {
case "profile":
@ -108,7 +119,15 @@ func Settings() http.HandlerFunc {
// Set profile attributes.
for _, attr := range config.ProfileFields {
user.SetProfileField(attr, r.PostFormValue(attr))
var value = strings.TrimSpace(r.PostFormValue(attr))
// Look for spammy links to restricted video sites or things.
if err := spam.DetectSpamMessage(value); err != nil {
session.FlashError(w, r, "On field '%s': %s", attr, err.Error())
continue
}
user.SetProfileField(attr, value)
}
// "Looking For" checkbox list.
@ -167,6 +186,7 @@ func Settings() http.HandlerFunc {
for _, field := range []string{
"hero-text-dark",
"card-lightness",
"website-theme",
} {
value := r.PostFormValue(field)
user.SetProfileField(field, value)
@ -204,6 +224,7 @@ func Settings() http.HandlerFunc {
var (
visibility = models.UserVisibility(r.PostFormValue("visibility"))
dmPrivacy = r.PostFormValue("dm_privacy")
ppPrivacy = r.PostFormValue("private_photo_gate")
)
user.Visibility = models.UserVisibilityPublic
@ -216,6 +237,7 @@ func Settings() http.HandlerFunc {
// Set profile field prefs.
user.SetProfileField("dm_privacy", dmPrivacy)
user.SetProfileField("private_photo_gate", ppPrivacy)
if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err)
@ -260,6 +282,28 @@ func Settings() http.HandlerFunc {
session.Flash(w, r, "Unsubscribed from all comment threads!")
}
}
case "push_notifications":
hashtag = "#notifications"
// Store their notification opt-outs.
for _, key := range config.PushNotificationOptOutFields {
var value = r.PostFormValue(key)
if value == "" {
value = "true" // opt-out, store opt-out=true in the DB
} else if value == "true" {
value = "false" // the box remained checked, they don't opt-out, store opt-out=false in the DB
}
// Save it.
user.SetProfileField(key, value)
}
session.Flash(w, r, "Notification preferences updated!")
// Save the user for new fields to be committed to DB.
if err := user.Save(); err != nil {
session.FlashError(w, r, "Failed to save user to database: %s", err)
}
case "location":
hashtag = "#location"
var (
@ -293,32 +337,91 @@ func Settings() http.HandlerFunc {
case "settings":
hashtag = "#account"
var (
oldPassword = r.PostFormValue("old_password")
changeEmail = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_email")))
password1 = strings.TrimSpace(r.PostFormValue("new_password"))
password2 = strings.TrimSpace(r.PostFormValue("new_password2"))
oldPassword = r.PostFormValue("old_password")
changeEmail = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_email")))
changeUsername = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_username")))
password1 = strings.TrimSpace(r.PostFormValue("new_password"))
password2 = strings.TrimSpace(r.PostFormValue("new_password2"))
)
// Their old password is needed to make any changes to their account.
if err := user.CheckPassword(oldPassword); err != nil {
session.FlashError(w, r, "Could not make changes to your account settings as the 'current password' you entered was incorrect.")
templates.Redirect(w, r.URL.Path)
templates.Redirect(w, r.URL.Path+hashtag)
return
}
// Changing their username?
if changeUsername != user.Username {
// Not if they are in the chat room!
if isOnChat {
session.FlashError(w, r, "Your username could not be changed right now because you are logged into the chat room. Please exit the chat room, wait a minute, and try your request again.")
templates.Redirect(w, r.URL.Path+hashtag)
return
}
// Check if the new name is OK.
if err := models.IsValidUsername(changeUsername); err != nil {
session.FlashError(w, r, "Could not change your username: %s", err.Error())
templates.Redirect(w, r.URL.Path+hashtag)
return
}
// Clear their history on the chat room.
go func(username string) {
log.Error("Change of username, clear chat history for old name %s", username)
i, err := chat.EraseChatHistory(username)
if err != nil {
log.Error("EraseChatHistory(%s): %s", username, err)
return
}
session.Flash(w, r, "Notice: due to your recent change in username, your direct message history on the Chat Room has been reset. %d message(s) had been removed.", i)
}(user.Username)
// Set their name.
origUsername := user.Username
user.Username = changeUsername
if err := user.Save(); err != nil {
session.FlashError(w, r, "Error saving your new username: %s", err)
} else {
session.Flash(w, r, "Your username has been updated to: %s", user.Username)
// Notify the admin about this to keep tabs if someone is acting strangely
// with too-frequent username changes.
fb := &models.Feedback{
Intent: "report",
Subject: "Change of username",
UserID: user.ID,
TableName: "users",
TableID: user.ID,
Message: fmt.Sprintf(
"A user has modified their username on their profile page!\n\n"+
"* Original: %s\n* Updated: %s",
origUsername, changeUsername,
),
}
// Save the feedback.
if err := models.CreateFeedback(fb); err != nil {
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
}
}
}
// Changing their email?
if changeEmail != user.Email {
// Validate the email.
if _, err := nm.ParseAddress(changeEmail); err != nil {
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
templates.Redirect(w, r.URL.Path)
templates.Redirect(w, r.URL.Path+hashtag)
return
}
// Email must not already exist.
if _, err := models.FindUser(changeEmail); err == nil {
session.FlashError(w, r, "That email address is already in use.")
templates.Redirect(w, r.URL.Path)
templates.Redirect(w, r.URL.Path+hashtag)
return
}
@ -330,7 +433,7 @@ func Settings() http.HandlerFunc {
}
if err := redis.Set(fmt.Sprintf(config.ChangeEmailRedisKey, token.Token), token, config.SignupTokenExpires); err != nil {
session.FlashError(w, r, "Failed to create change email token: %s", err)
templates.Redirect(w, r.URL.Path)
templates.Redirect(w, r.URL.Path+hashtag)
return
}
@ -374,6 +477,13 @@ func Settings() http.HandlerFunc {
session.FlashError(w, r, "Unknown POST intent value. Please try again.")
}
// Maybe kick them from the chat room if they had become a Shy Account.
if !wasShy && user.IsShy() {
if _, err := chat.MaybeDisconnectUser(user); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
}
}
templates.Redirect(w, r.URL.Path+hashtag+".")
return
}
@ -392,6 +502,9 @@ func Settings() http.HandlerFunc {
// Count of subscribed comment threads.
vars["SubscriptionCount"] = models.CountSubscriptions(user)
// Count of push notification subscriptions.
vars["PushNotificationsCount"] = models.CountPushNotificationSubscriptions(user)
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return

View File

@ -14,6 +14,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/redis"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/spam"
"code.nonshy.com/nonshy/website/pkg/templates"
"code.nonshy.com/nonshy/website/pkg/utility"
"github.com/google/uuid"
@ -60,7 +61,6 @@ func Signup() http.HandlerFunc {
}
var token SignupToken
log.Info("SignupToken: %s", tokenStr)
if tokenStr != "" {
// Validate it.
if err := redis.Get(fmt.Sprintf(config.SignupTokenRedisKey, tokenStr), &token); err != nil || token.Token != tokenStr {
@ -72,7 +72,6 @@ func Signup() http.HandlerFunc {
vars["SignupToken"] = tokenStr
vars["Email"] = token.Email
}
log.Info("Vars: %+v", vars)
// Posting?
if r.Method == http.MethodPost {
@ -86,8 +85,34 @@ func Signup() http.HandlerFunc {
password = strings.TrimSpace(r.PostFormValue("password"))
password2 = strings.TrimSpace(r.PostFormValue("password2"))
dob = r.PostFormValue("dob")
// CAPTCHA response.
turnstileCAPTCHA = r.PostFormValue("cf-turnstile-response")
// Honeytrap fields for lazy spam bots.
honeytrap1 = r.PostFormValue("phone") == ""
honeytrap2 = r.PostFormValue("referral") == "Word of mouth"
// Validation errors but still show the form again.
hasError bool
)
// Honeytrap fields check.
if !honeytrap1 || !honeytrap2 {
session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email)
templates.Redirect(w, r.URL.Path)
return
}
// Validate the CAPTCHA token.
if config.Current.Turnstile.Enabled {
if err := spam.ValidateTurnstileCAPTCHA(turnstileCAPTCHA, "signup"); err != nil {
session.FlashError(w, r, "There was an error validating your CAPTCHA response.")
templates.Redirect(w, r.URL.Path)
return
}
}
// Don't let them sneakily change their verified email address on us.
if vars["SignupToken"] != "" && email != vars["Email"] {
session.FlashError(w, r, "This email address is not verified. Please start over from the beginning.")
@ -95,27 +120,10 @@ func Signup() http.HandlerFunc {
return
}
// Reserved username check.
for _, cmp := range config.ReservedUsernames {
if username == cmp {
session.FlashError(w, r, "That username is reserved, please choose a different username.")
templates.Redirect(w, r.URL.Path+"?token="+tokenStr)
return
}
}
// Cache username in case of passwd validation errors.
vars["Email"] = email
vars["Username"] = username
// Is the app not configured to send email?
if !config.Current.Mail.Enabled {
session.FlashError(w, r, "This app is not configured to send email so you can not sign up at this time. "+
"Please contact the website administrator about this issue!")
templates.Redirect(w, r.URL.Path)
return
}
// Validate the email.
if _, err := nm.ParseAddress(email); err != nil {
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
@ -131,20 +139,29 @@ func Signup() http.HandlerFunc {
}
// Already an account?
if _, err := models.FindUser(email); err == nil {
if user, err := models.FindUser(email); err == nil {
// We don't want to admit that the email already is registered, so send an email to the
// address in case the user legitimately forgot, but flash the regular success message.
err := mail.Send(mail.Message{
To: email,
Subject: "You already have a nonshy account",
Template: "email/already_signed_up.html",
Data: map[string]interface{}{
"Title": config.Title,
"URL": config.Current.BaseURL + "/forgot-password",
},
})
if err != nil {
session.FlashError(w, r, "Error sending an email: %s", err)
if user.IsBanned() {
log.Error("Do not send signup e-mail to %s: user is banned", email)
} else {
if err := mail.LockSending("signup", email, config.EmailDebounceDefault); err == nil {
err := mail.Send(mail.Message{
To: email,
Subject: "You already have a nonshy account",
Template: "email/already_signed_up.html",
Data: map[string]interface{}{
"Title": config.Title,
"URL": config.Current.BaseURL + "/forgot-password",
},
})
if err != nil {
session.FlashError(w, r, "Error sending an email: %s", err)
}
} else {
log.Error("LockSending: signup e-mail is not sent to %s: one was sent recently", email)
}
}
session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email)
@ -163,20 +180,38 @@ func Signup() http.HandlerFunc {
session.FlashError(w, r, "Error creating a link to send you: %s", err)
}
err := mail.Send(mail.Message{
To: email,
Subject: "Verify your e-mail address",
Template: "email/verify_email.html",
Data: map[string]interface{}{
"Title": config.Title,
"URL": config.Current.BaseURL + "/signup?token=" + token.Token,
},
})
if err != nil {
session.FlashError(w, r, "Error sending an email: %s", err)
// Is the app not configured to send email?
if !config.Current.Mail.Enabled && !config.SkipEmailVerification {
// Log the signup token for local dev.
log.Error("Signup: the app is not configured to send email. To continue, visit the URL: /signup?token=%s", token.Token)
session.FlashError(w, r, "This app is not configured to send email so you can not sign up at this time. "+
"Please contact the website administrator about this issue!")
templates.Redirect(w, r.URL.Path)
return
}
if err := mail.LockSending("signup", email, config.SignupTokenExpires); err == nil {
err := mail.Send(mail.Message{
To: email,
Subject: "Verify your e-mail address",
Template: "email/verify_email.html",
Data: map[string]interface{}{
"Title": config.Title,
"URL": config.Current.BaseURL + "/signup?token=" + token.Token,
},
})
if err != nil {
session.FlashError(w, r, "Error sending an email: %s", err)
}
} else {
log.Error("LockSending: signup e-mail is not sent to %s: one was sent recently", email)
}
session.Flash(w, r, "We have sent an e-mail to %s with a link to continue signing up your account. Please go and check your e-mail.", email)
// Reminder to check their spam folder too (Gmail users)
session.Flash(w, r, "If you don't see the confirmation e-mail, check in case it went to your spam folder.")
templates.Redirect(w, r.URL.Path)
return
}
@ -205,7 +240,6 @@ func Signup() http.HandlerFunc {
}
// Full sign-up step (w/ email verification token), validate more things.
var hasError bool
if len(password) < 3 {
session.FlashError(w, r, "Please enter a password longer than 3 characters.")
hasError = true
@ -214,8 +248,9 @@ func Signup() http.HandlerFunc {
hasError = true
}
if !config.UsernameRegexp.MatchString(username) {
session.FlashError(w, r, "Your username must consist of only numbers, letters, - . and be 3-32 characters.")
// Validate the username is OK: well formatted, not reserved, not existing.
if err := models.IsValidUsername(username); err != nil {
session.FlashError(w, r, err.Error())
hasError = true
}

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,15 @@ import (
"code.nonshy.com/nonshy/website/pkg/templates"
)
var NotesURLRegexp = regexp.MustCompile(`^/notes/u/([^@]+?)$`)
// User notes page (/notes/u/username)
func UserNotes() http.HandlerFunc {
tmpl := templates.Must("account/user_notes.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the username out of the URL parameters.
var username string
m := NotesURLRegexp.FindStringSubmatch(r.URL.Path)
if m != nil {
username = m[1]
}
var (
username = r.PathValue("username")
show = r.FormValue("show") // admin feedback filter
)
// Find this user.
user, err := models.FindUser(username)
@ -115,7 +111,7 @@ func UserNotes() http.HandlerFunc {
}
// Paginate feedback & reports.
if fb, err := models.PaginateFeedbackAboutUser(user, fbPager); err != nil {
if fb, err := models.PaginateFeedbackAboutUser(user, show, fbPager); err != nil {
session.FlashError(w, r, "Paginating feedback on this user: %s", err)
} else {
feedback = fb
@ -148,6 +144,7 @@ func UserNotes() http.HandlerFunc {
"MyNote": myNote,
// Admin concerns.
"Show": show,
"Feedback": feedback,
"FeedbackPager": fbPager,
"OtherNotes": otherNotes,
@ -208,7 +205,7 @@ func MyNotes() http.HandlerFunc {
}
// Admin notes?
if adminNotes && !currentUser.IsAdmin {
if adminNotes && !currentUser.HasAdminScope(config.ScopeUserNotes) {
adminNotes = false
}

View File

@ -0,0 +1,62 @@
package admin
import (
"net/http"
"net/mail"
"strings"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// Manually create new user accounts.
func AddUser() http.HandlerFunc {
tmpl := templates.Must("admin/add_user.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var (
email = strings.TrimSpace(strings.ToLower(r.PostFormValue("email")))
username = strings.TrimSpace(strings.ToLower(r.PostFormValue("username")))
password = r.PostFormValue("password")
)
// Validate the email.
if _, err := mail.ParseAddress(email); err != nil {
session.FlashError(w, r, "The email address you entered is not valid: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
// Password check.
if len(password) < 3 {
session.FlashError(w, r, "The password is required to be 3+ characters long.")
templates.Redirect(w, r.URL.Path)
return
}
// Validate the username is OK: well formatted, not reserved, not existing.
if err := models.IsValidUsername(username); err != nil {
session.FlashError(w, r, err.Error())
templates.Redirect(w, r.URL.Path)
return
}
// Create the user.
if _, err := models.CreateUser(username, email, password); err != nil {
session.FlashError(w, r, "Couldn't create the user: %s", err)
templates.Redirect(w, r.URL.Path)
return
}
session.Flash(w, r, "Created the username %s with password: %s", username, password)
templates.Redirect(w, r.URL.Path)
return
}
if err := tmpl.Execute(w, r, nil); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

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

@ -3,6 +3,7 @@ package admin
import (
"net/http"
"code.nonshy.com/nonshy/website/pkg/encryption/coldstorage"
"code.nonshy.com/nonshy/website/pkg/templates"
)
@ -10,7 +11,10 @@ import (
func Dashboard() http.HandlerFunc {
tmpl := templates.Must("admin/dashboard.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := tmpl.Execute(w, r, nil); err != nil {
var vars = map[string]interface{}{
"ColdStorageWarning": coldstorage.Warning(),
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

View File

@ -14,6 +14,13 @@ import (
// Feedback controller (/admin/feedback)
func Feedback() http.HandlerFunc {
tmpl := templates.Must("admin/feedback.html")
// Whitelist for ordering options.
var sortWhitelist = []string{
"created_at desc",
"created_at asc",
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query params.
var (
@ -23,8 +30,26 @@ func Feedback() http.HandlerFunc {
profile = r.FormValue("profile") == "true" // visit associated user profile
verdict = r.FormValue("verdict")
fb *models.Feedback
// Search filters.
searchQuery = r.FormValue("q")
search = models.ParseSearchString(searchQuery)
subject = r.FormValue("subject")
sort = r.FormValue("sort")
sortOK bool
)
// Sort options.
for _, v := range sortWhitelist {
if sort == v {
sortOK = true
break
}
}
if !sortOK {
sort = sortWhitelist[0]
}
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Couldn't get your current user: %s", err)
@ -44,32 +69,50 @@ func Feedback() http.HandlerFunc {
// Are we visiting a linked resource (via TableID)?
if fb != nil && fb.TableID > 0 && visit {
// New (Oct 17 '24): feedbacks may carry an AboutUserID, e.g. for photos in case the reported
// photo is removed then the associated owner of the photo is still carried in the report.
var aboutUser *models.User
if fb.AboutUserID > 0 {
if user, err := models.GetUser(fb.AboutUserID); err == nil {
aboutUser = user
}
}
switch fb.TableName {
case "users":
user, err := models.GetUser(fb.TableID)
if err != nil {
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
} else {
// If this is an "inner circle removal" report, go to their gallery and filter pics by Public.
if fb.Intent == "report.circle" {
templates.Redirect(w, "/photo/u/"+user.Username+"?visibility=public")
} else {
templates.Redirect(w, "/u/"+user.Username)
}
templates.Redirect(w, "/u/"+user.Username)
return
}
case "photos":
pic, err := models.GetPhoto(fb.TableID)
if err != nil {
// If there was an About User, visit their profile page instead.
if aboutUser != nil {
session.FlashError(w, r, "The photo #%d was deleted, visiting the owner's profile page instead.", fb.TableID)
templates.Redirect(w, "/u/"+aboutUser.Username)
return
}
session.FlashError(w, r, "Couldn't get photo %d: %s", fb.TableID, err)
} else {
// Going to the user's profile page?
if profile {
user, err := models.GetUser(pic.UserID)
if err != nil {
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
} else {
templates.Redirect(w, "/u/"+user.Username)
// Going forward: the aboutUser will be populated, this is for legacy reports.
if aboutUser == nil {
if user, err := models.GetUser(pic.UserID); err == nil {
aboutUser = user
} else {
session.FlashError(w, r, "Couldn't visit user %d: %s", fb.TableID, err)
}
}
if aboutUser != nil {
templates.Redirect(w, "/u/"+aboutUser.Username)
return
}
}
@ -107,6 +150,15 @@ func Feedback() http.HandlerFunc {
return
}
}
case "forums":
// Get this forum.
forum, err := models.GetForum(fb.TableID)
if err != nil {
session.FlashError(w, r, "Couldn't get comment ID %d: %s", fb.TableID, err)
} else {
templates.Redirect(w, fmt.Sprintf("/f/%s", forum.Fragment))
return
}
default:
session.FlashError(w, r, "Couldn't visit TableID %s/%d: not a supported TableName", fb.TableName, fb.TableID)
}
@ -145,31 +197,51 @@ func Feedback() http.HandlerFunc {
pager := &models.Pagination{
Page: 1,
PerPage: config.PageSizeAdminFeedback,
Sort: "updated_at desc",
Sort: sort,
}
pager.ParsePage(r)
page, err := models.PaginateFeedback(acknowledged, intent, pager)
page, err := models.PaginateFeedback(acknowledged, intent, subject, search, pager)
if err != nil {
session.FlashError(w, r, "Couldn't load feedback from DB: %s", err)
}
// Map user IDs.
var userIDs = []uint64{}
var (
userIDs = []uint64{}
photoIDs = []uint64{}
)
for _, p := range page {
if p.UserID > 0 {
userIDs = append(userIDs, p.UserID)
}
if p.TableName == "photos" && p.TableID > 0 {
photoIDs = append(photoIDs, p.TableID)
}
}
userMap, err := models.MapUsers(currentUser, userIDs)
if err != nil {
session.FlashError(w, r, "Couldn't map user IDs: %s", err)
}
// Map photo IDs.
photoMap, err := models.MapPhotos(photoIDs)
if err != nil {
session.FlashError(w, r, "Couldn't map photo IDs: %s", err)
}
var vars = map[string]interface{}{
// Filter settings.
"DistinctSubjects": models.DistinctFeedbackSubjects(),
"SearchTerm": searchQuery,
"Subject": subject,
"Sort": sort,
"Intent": intent,
"Acknowledged": acknowledged,
"Feedback": page,
"UserMap": userMap,
"PhotoMap": photoMap,
"Pager": pager,
}
if err := tmpl.Execute(w, r, vars); err != nil {

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 {
@ -41,11 +52,18 @@ func MarkPhotoExplicit() http.HandlerFunc {
}
photo.Explicit = true
photo.Flagged = true
if err := photo.Save(); err != nil {
session.FlashError(w, r, "Couldn't save photo: %s", err)
} else {
session.Flash(w, r, "Marked photo as Explicit!")
}
// Log the change.
models.LogUpdated(&models.User{ID: photo.UserID}, currentUser, "photos", photo.ID, "Marked explicit by admin action.", []models.FieldDiff{
models.NewFieldDiff("Explicit", false, true),
})
templates.Redirect(w, next)
})
}
@ -100,11 +118,88 @@ func UserActions() http.HandlerFunc {
return
}
// Get their block lists.
insights, err := models.GetBlocklistInsights(user)
if err != nil {
session.FlashError(w, r, "Error getting blocklist insights: %s", err)
}
vars["BlocklistInsights"] = insights
// Also surface counts of admin blocks.
count, total := models.CountBlockedAdminUsers(user)
vars["AdminBlockCount"] = count
vars["AdminBlockTotal"] = total
case "chat.rules":
// Chat Moderation Rules.
if !currentUser.HasAdminScope(config.ScopeChatModerator) {
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeChatModerator)
templates.Redirect(w, "/admin")
return
}
if r.Method == http.MethodPost {
// Rules list for the change log.
var newRules = "(none)"
if rule, ok := r.PostForm["rules"]; ok && len(rule) > 0 {
newRules = strings.Join(rule, ",")
user.SetProfileField("chat_moderation_rules", newRules)
if err := user.Save(); err != nil {
session.FlashError(w, r, "Error saving the user's chat rules: %s", err)
} else {
session.Flash(w, r, "Chat moderation rules have been updated!")
}
} else {
user.DeleteProfileField("chat_moderation_rules")
session.Flash(w, r, "All chat moderation rules have been cleared for user: %s", user.Username)
}
templates.Redirect(w, "/u/"+user.Username)
// Log the new rules to the changelog.
models.LogEvent(
user,
currentUser,
"updated",
"chat.rules",
user.ID,
fmt.Sprintf(
"An admin has updated the chat moderation rules for this user.\n\n"+
"The update rules are: %s",
newRules,
),
)
return
}
vars["ChatModerationRules"] = config.ChatModerationRules
case "essays":
// Edit their profile essays easily.
if !currentUser.HasAdminScope(config.ScopePhotoModerator) {
session.FlashError(w, r, "Missing admin scope: %s", config.ScopePhotoModerator)
templates.Redirect(w, "/admin")
return
}
if r.Method == http.MethodPost {
var (
about = r.PostFormValue("about_me")
interests = r.PostFormValue("interests")
musicMovies = r.PostFormValue("music_movies")
)
user.SetProfileField("about_me", about)
user.SetProfileField("interests", interests)
user.SetProfileField("music_movies", musicMovies)
if err := user.Save(); err != nil {
session.FlashError(w, r, "Error saving the user: %s", err)
} else {
session.Flash(w, r, "Their profile text has been updated!")
}
templates.Redirect(w, "/u/"+user.Username)
return
}
case "impersonate":
// Scope check.
if !currentUser.HasAdminScope(config.ScopeUserImpersonate) {
@ -141,6 +236,14 @@ func UserActions() http.HandlerFunc {
user.Save()
session.Flash(w, r, "User ban status updated!")
templates.Redirect(w, "/u/"+user.Username)
// Maybe kick them from chat room now.
if _, err := chat.MaybeDisconnectUser(user); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
}
// Log the change.
models.LogEvent(user, currentUser, models.ChangeLogBanned, "users", currentUser.ID, fmt.Sprintf("User ban status updated to: %s", status))
return
}
case "promote":
@ -156,6 +259,34 @@ func UserActions() http.HandlerFunc {
user.IsAdmin = action == "promote"
user.Save()
session.Flash(w, r, "User admin status updated!")
templates.Redirect(w, "/u/"+user.Username)
// Log the change.
models.LogEvent(user, currentUser, models.ChangeLogAdmin, "users", currentUser.ID, fmt.Sprintf("User admin status updated to: %s", action))
return
}
case "password":
// Scope check.
if !currentUser.HasAdminScope(config.ScopeUserPassword) {
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeUserPassword)
templates.Redirect(w, "/admin")
return
}
if confirm {
password := r.PostFormValue("password")
if len(password) < 3 {
session.FlashError(w, r, "A password of at least 3 characters is required.")
templates.Redirect(w, r.URL.Path+fmt.Sprintf("?intent=password&user_id=%d", user.ID))
return
}
if err := user.SaveNewPassword(password); err != nil {
session.FlashError(w, r, "Failed to set the user's password: %s", err)
} else {
session.Flash(w, r, "The user's password has been updated to: %s", password)
}
templates.Redirect(w, "/u/"+user.Username)
return
}
@ -174,6 +305,14 @@ func UserActions() http.HandlerFunc {
session.Flash(w, r, "User has been deleted!")
}
templates.Redirect(w, "/admin")
// Kick them from the chat room if they are online.
if _, err := chat.DisconnectUserNow(user, "You have been signed out of chat because your account has been deleted."); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
}
// Log the change.
models.LogDeleted(nil, currentUser, "users", user.ID, fmt.Sprintf("Username %s has been deleted by an admin.", user.Username), nil)
return
}
default:

View File

@ -79,6 +79,9 @@ func Report() http.HandlerFunc {
log.Debug("Got chat report: %+v", report)
// Make a clickable profile link for the channel ID (other user).
otherUsername := strings.TrimPrefix(report.Channel, "@")
// Create an admin Feedback model.
fb := &models.Feedback{
Intent: "report",
@ -87,7 +90,7 @@ func Report() http.HandlerFunc {
"A message was reported on the chat room!\n\n"+
"* From username: [%s](/u/%s)\n"+
"* About username: [%s](/u/%s)\n"+
"* Channel: **%s**\n"+
"* Channel: [**%s**](/u/%s)\n"+
"* Timestamp: %s\n"+
"* Classification: %s\n"+
"* User comment: %s\n\n"+
@ -95,7 +98,7 @@ func Report() http.HandlerFunc {
"The reported message on chat was:\n\n%s",
report.FromUsername, report.FromUsername,
report.AboutUsername, report.AboutUsername,
report.Channel,
report.Channel, otherUsername,
report.Timestamp,
report.Reason,
report.Comment,
@ -116,6 +119,7 @@ func Report() http.HandlerFunc {
if err == nil {
fb.TableName = "users"
fb.TableID = targetUser.ID
fb.AboutUserID = targetUser.ID
} else {
log.Error("BareRTC Chat Feedback: couldn't find user ID for AboutUsername=%s: %s", report.AboutUsername, err)
}
@ -193,12 +197,20 @@ func Profile() http.HandlerFunc {
var photoCount = models.CountPublicPhotos(currentUser.ID)
// Member Since date.
var memberSinceDate = currentUser.CreatedAt
if currentUser.Certified {
if dt, err := currentUser.CertifiedSince(); err == nil {
memberSinceDate = dt
}
}
var resp = Response{
OK: true,
ProfileFields: []ProfileField{
{
Name: "Member Since",
Value: fmt.Sprintf("%s ago", utility.FormatDurationCoarse(time.Since(currentUser.CreatedAt))),
Name: "Certified since",
Value: fmt.Sprintf("%s ago", utility.FormatDurationCoarse(time.Since(memberSinceDate))),
},
{
Name: "📸 Gallery",

View File

@ -49,3 +49,17 @@ func SendJSON(w http.ResponseWriter, statusCode int, v interface{}) {
w.WriteHeader(statusCode)
w.Write(buf)
}
// SendRawJSON response without the standard API wrapper.
func SendRawJSON(w http.ResponseWriter, statusCode int, v interface{}) {
buf, err := json.Marshal(v)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
w.Write(buf)
}

View File

@ -8,6 +8,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/photo"
"code.nonshy.com/nonshy/website/pkg/session"
)
@ -96,25 +97,20 @@ func Likes() http.HandlerFunc {
case "photos":
if photo, err := models.GetPhoto(tableID); err == nil {
if user, err := models.GetUser(photo.UserID); err == nil {
// Admin safety check: in case the admin clicked 'Like' on a friends-only or private
// picture they shouldn't have been expected to see, do not log a like.
if currentUser.IsAdmin && currentUser.ID != user.ID {
if (photo.Visibility == models.PhotoFriends && !models.AreFriends(user.ID, currentUser.ID)) ||
(photo.Visibility == models.PhotoPrivate && !models.IsPrivateUnlocked(user.ID, currentUser.ID)) {
SendJSON(w, http.StatusForbidden, Response{
Error: "You are not allowed to like that photo.",
})
return
}
}
// Blocking safety check: if either user blocks the other, liking is not allowed.
if models.IsBlocking(currentUser.ID, user.ID) {
// Safety check: if the current user should not see this picture, they can not "Like" it.
// Example: you unfriended them but they still had the image on their old browser page.
if ok, _ := photo.ShouldBeSeenBy(currentUser); !ok {
SendJSON(w, http.StatusForbidden, Response{
Error: "You are not allowed to like that photo.",
})
return
}
// Mark this photo as 'viewed' if it received a like.
// Example: on a gallery view the photo is only 'viewed' if interacted with (lightbox),
// going straight for the 'Like' button should count as well.
photo.View(currentUser)
targetUser = user
}
} else {
@ -124,7 +120,6 @@ func Likes() http.HandlerFunc {
log.Error("subject is users, find %d", tableID)
if user, err := models.GetUser(tableID); err == nil {
targetUser = user
log.Warn("found user %s", targetUser.Username)
// Blocking safety check: if either user blocks the other, liking is not allowed.
if models.IsBlocking(currentUser.ID, user.ID) {
@ -175,7 +170,7 @@ func Likes() http.HandlerFunc {
}
// Remove the target's notification about this like.
models.RemoveSpecificNotification(targetUser.ID, models.NotificationLike, req.TableName, tableID)
models.RemoveSpecificNotificationAboutUser(targetUser.ID, currentUser.ID, models.NotificationLike, req.TableName, tableID)
} else {
if err := models.AddLike(currentUser, req.TableName, tableID); err != nil {
SendJSON(w, http.StatusBadRequest, Response{
@ -202,6 +197,13 @@ func Likes() http.HandlerFunc {
}
}
// Refresh cached like counts.
if req.TableName == "photos" {
if err := models.UpdatePhotoCachedCounts(tableID); err != nil {
log.Error("UpdatePhotoCachedCount(%d): %s", tableID, err)
}
}
// Send success response.
SendJSON(w, http.StatusOK, Response{
OK: true,
@ -284,7 +286,7 @@ func WhoLikes() http.HandlerFunc {
for _, user := range users {
result = append(result, Liker{
Username: user.Username,
Avatar: user.VisibleAvatarURL(currentUser),
Avatar: photo.VisibleAvatarURL(user, currentUser),
Relationship: user.UserRelationship,
})
}

View File

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

View File

@ -1,93 +0,0 @@
package api
import (
"fmt"
"net/http"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/photo"
)
// RemoveOrphanedCommentPhotos API.
//
// URL: /v1/comment-photos/remove-orphaned
//
// Query parameters: ?apiKey={CronAPIKey}
//
// This endpoint looks for CommentPhotos having a blank CommentID that were created older
// than 24 hours ago and removes them. Configure the "CronAPIKey" in your settings.json
// and pass it as the query parameter.
func RemoveOrphanedCommentPhotos() http.HandlerFunc {
// Response JSON schema.
type Response struct {
OK bool `json:"OK"`
Error string `json:"error,omitempty"`
Total int64 `json:"total"`
Removed int `json:"removed"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
SendJSON(w, http.StatusNotAcceptable, Response{
Error: "GET method only",
})
return
}
// Get and validate the API key.
var (
apiKey = r.FormValue("apiKey")
compare = config.Current.CronAPIKey
)
if compare == "" {
SendJSON(w, http.StatusInternalServerError, Response{
OK: false,
Error: "app CronAPIKey is not configured",
})
return
} else if apiKey == "" || apiKey != compare {
SendJSON(w, http.StatusInternalServerError, Response{
OK: false,
Error: "invalid apiKey query parameter",
})
return
}
// Do the needful.
photos, total, err := models.GetOrphanedCommentPhotos()
if err != nil {
SendJSON(w, http.StatusInternalServerError, Response{
OK: false,
Error: fmt.Sprintf("GetOrphanedCommentPhotos: %s", err),
})
return
}
for _, row := range photos {
if err := photo.Delete(row.Filename); err != nil {
SendJSON(w, http.StatusInternalServerError, Response{
OK: false,
Error: fmt.Sprintf("Photo ID %d: removing file %s: %s", row.ID, row.Filename, err),
})
return
}
if err := row.Delete(); err != nil {
SendJSON(w, http.StatusInternalServerError, Response{
OK: false,
Error: fmt.Sprintf("DeleteOrphanedCommentPhotos(%d): %s", row.ID, err),
})
return
}
}
// Send success response.
SendJSON(w, http.StatusOK, Response{
OK: true,
Total: total,
Removed: len(photos),
})
})
}

View File

@ -0,0 +1,70 @@
package api
import (
"net/http"
"strconv"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
)
// ViewPhoto API pings a view count on a photo, e.g. from the lightbox modal.
func ViewPhoto() http.HandlerFunc {
// Response JSON schema.
type Response struct {
OK bool `json:"OK"`
Error string `json:"error,omitempty"`
Likes int64 `json:"likes"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
SendJSON(w, http.StatusBadRequest, Response{
Error: "Couldn't get current user!",
})
return
}
// Photo ID from path parameter.
var photoID uint64
if id, err := strconv.Atoi(r.PathValue("photo_id")); err == nil && id > 0 {
photoID = uint64(id)
} else {
SendJSON(w, http.StatusBadRequest, Response{
Error: "Invalid photo ID",
})
return
}
// Find this photo.
photo, err := models.GetPhoto(photoID)
if err != nil {
SendJSON(w, http.StatusNotFound, Response{
Error: "Photo Not Found",
})
return
}
// Check permission to have seen this photo.
if ok, err := photo.ShouldBeSeenBy(currentUser); !ok {
log.Error("Photo %d can't be seen by %s: %s", photo.ID, currentUser.Username, err)
SendJSON(w, http.StatusNotFound, Response{
Error: "Photo Not Found",
})
return
}
// Mark a view.
if err := photo.View(currentUser); err != nil {
log.Error("Update photo(%d) views: %s", photo.ID, err)
}
// Send success response.
SendJSON(w, http.StatusOK, Response{
OK: true,
})
})
}

View File

@ -0,0 +1,106 @@
package api
import (
"fmt"
"net/http"
"net/url"
"strings"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/encryption"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/photo"
"code.nonshy.com/nonshy/website/pkg/session"
)
// PhotoSignAuth API protects paths like /static/photos/ to authenticated user requests only.
func PhotoSignAuth() http.HandlerFunc {
type Response struct {
Success bool `json:"success"`
Error string `json:",omitempty"`
Username string `json:"username"`
}
logAndError := func(w http.ResponseWriter, m string, v ...interface{}) {
log.Debug("ERROR PhotoSignAuth: "+m, v...)
SendJSON(w, http.StatusForbidden, Response{
Error: fmt.Sprintf(m, v...),
})
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// We only protect the /static/photos subpath.
// And check if the SignedPhoto feature is enabled and enforcing.
var originalURI = r.Header.Get("X-Original-URI")
if !config.Current.SignedPhoto.Enabled || !strings.HasPrefix(originalURI, config.PhotoWebPath) {
SendJSON(w, http.StatusOK, Response{
Success: true,
})
return
}
// Get the base filename.
var filename = strings.TrimPrefix(
strings.SplitN(originalURI, config.PhotoWebPath, 2)[1],
"/",
)
filename = strings.SplitN(filename, "?", 2)[0] // inner query string too
// Parse the JWT token parameter from the original URL.
var token string
if path, err := url.Parse(originalURI); err == nil {
query := path.Query()
token = query.Get("jwt")
}
// The JWT token is required from here on out.
if token == "" {
logAndError(w, "JWT token is required")
return
}
// Check if we're logged in and who the current username is.
var username string
if currentUser, err := session.CurrentUser(r); err == nil {
username = currentUser.Username
}
// Validate the JWT token is correctly signed and not expired.
claims, ok, err := encryption.ValidateClaims(
token,
[]byte(config.Current.SignedPhoto.JWTSecret),
&photo.SignedPhotoClaims{},
)
if !ok || err != nil {
logAndError(w, "When validating JWT claims: %v", err)
return
}
// Parse the claims to get data to validate this request.
c, ok := claims.(*photo.SignedPhotoClaims)
if !ok {
logAndError(w, "JWT claims were not the correct shape: %+v", claims)
return
}
// Was the signature for our username? (Skip if for Anyone)
if !c.Anyone && c.Subject != username {
logAndError(w, "That token did not belong to you")
return
}
// Is the file name correct?
hash := photo.FilenameHash(filename)
if hash != c.FilenameHash {
logAndError(w, "Filename hash mismatch: fn=%s hash=%s jwt=%s", filename, hash, c.FilenameHash)
return
}
log.Debug("PhotoSignAuth: JWT Signature OK! fn=%s u=%s anyone=%v expires=%+v", filename, c.Subject, c.Anyone, c.ExpiresAt)
SendJSON(w, http.StatusOK, Response{
Success: true,
Username: username,
})
})
}

View File

@ -0,0 +1,34 @@
package api
import (
"net/http"
"code.nonshy.com/nonshy/website/pkg/models"
)
// WorldCities API searches the location database for a world city location.
func WorldCities() http.HandlerFunc {
type Result struct {
ID uint64 `json:"id"`
Value string `json:"value"`
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var query = r.FormValue("query")
if query == "" {
SendRawJSON(w, http.StatusOK, []Result{})
return
}
result, err := models.SearchWorldCities(query)
if err != nil {
SendRawJSON(w, http.StatusInternalServerError, []Result{{
ID: 1,
Value: err.Error(),
}})
return
}
SendRawJSON(w, http.StatusOK, result)
})
}

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
@ -109,17 +112,35 @@ func BlockUser() http.HandlerFunc {
return
}
// Can't block admins who have the unblockable scope.
if user.IsAdmin && user.HasAdminScope(config.ScopeUnblockable) {
// If the target user is an admin, log this to the admin reports page.
if user.IsAdmin {
// Is the target admin user unblockable?
var (
unblockable = user.HasAdminScope(config.ScopeUnblockable)
footer string // qualifier for the admin report body
)
// Add a footer to the report to indicate whether the block goes through.
if unblockable {
footer = "**Unblockable:** this admin can not be blocked, so the block was not added and the user was shown an error message."
} else {
footer = "**Notice:** This admin is not unblockable, so the block has been added successfully."
}
// Also, include this user's current count of blocked admin users.
count, total := models.CountBlockedAdminUsers(currentUser)
footer += fmt.Sprintf("\n\nThis user now blocks %d of %d admin user(s) on this site.", count+1, total)
// For curiosity's sake, log a report.
fb := &models.Feedback{
Intent: "report",
Subject: "A user tried to block an admin",
Message: fmt.Sprintf(
"A user has tried to block an admin user account!\n\n"+
"* Username: %s\n* Tried to block: %s",
"* Username: %s\n* Tried to block: %s\n\n%s",
currentUser.Username,
user.Username,
footer,
),
UserID: currentUser.ID,
TableName: "users",
@ -129,9 +150,12 @@ func BlockUser() http.HandlerFunc {
log.Error("Could not log feedback for user %s trying to block admin %s: %s", currentUser.Username, user.Username, err)
}
session.FlashError(w, r, "You can not block site administrators.")
templates.Redirect(w, "/u/"+username)
return
// If the admin is unblockable, give the user an error message and return.
if unblockable {
session.FlashError(w, r, "You can not block site administrators.")
templates.Redirect(w, "/u/"+username)
return
}
}
// Block the target user.
@ -139,6 +163,9 @@ func BlockUser() http.HandlerFunc {
session.FlashError(w, r, "Couldn't block this user: %s.", err)
} else {
session.Flash(w, r, "You have added %s to your block list.", user.Username)
// Log the change.
models.LogCreated(currentUser, "blocks", user.ID, "Blocks user "+user.Username+".")
}
// Sync the block to the BareRTC chat server now, in case either user is currently online.

View File

@ -3,7 +3,6 @@ package chat
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
@ -11,6 +10,7 @@ import (
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/encryption"
"code.nonshy.com/nonshy/website/pkg/geoip"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/middleware"
@ -22,16 +22,17 @@ import (
"github.com/golang-jwt/jwt/v4"
)
// JWT claims.
// Claims are the JWT claims for the BareRTC chat room.
type Claims struct {
// Custom claims.
IsAdmin bool `json:"op,omitempty"`
VIP bool `json:"vip,omitempty"`
Avatar string `json:"img,omitempty"`
ProfileURL string `json:"url,omitempty"`
Nickname string `json:"nick,omitempty"`
Emoji string `json:"emoji,omitempty"`
Gender string `json:"gender,omitempty"`
IsAdmin bool `json:"op,omitempty"`
VIP bool `json:"vip,omitempty"`
Avatar string `json:"img,omitempty"`
ProfileURL string `json:"url,omitempty"`
Nickname string `json:"nick,omitempty"`
Emoji string `json:"emoji,omitempty"`
Gender string `json:"gender,omitempty"`
Rules []string `json:"rules,omitempty"`
// Standard claims. Notes:
// subject = username
@ -76,16 +77,6 @@ func Landing() http.HandlerFunc {
return
}
// If we are shy, block chat for now.
if isShy {
session.FlashError(w, r,
"You have a Shy Account and are not allowed in the chat room at this time where our non-shy members may "+
"be on camera.",
)
templates.Redirect(w, "/chat")
return
}
// Get our Chat JWT secret.
var (
secret = []byte(config.Current.BareRTC.JWTSecret)
@ -98,14 +89,12 @@ func Landing() http.HandlerFunc {
}
// Avatar URL - masked if non-public.
avatar := photo.URLPath(currentUser.ProfilePhoto.CroppedFilename)
avatar := photo.SignedPublicAvatarURL(currentUser.ProfilePhoto.CroppedFilename)
switch currentUser.ProfilePhoto.Visibility {
case models.PhotoPrivate:
avatar = "/static/img/shy-private.png"
case models.PhotoFriends:
avatar = "/static/img/shy-friends.png"
case models.PhotoInnerCircle:
avatar = "/static/img/shy-secret.png"
}
// Country flag emoji.
@ -122,26 +111,29 @@ func Landing() http.HandlerFunc {
emoji = "🍰 It's my birthday!"
}
// Apply chat moderation rules.
var rules = []string{}
if isShy {
// Shy account: no camera privileges.
rules = []string{"novideo", "noimage"}
} else if v := currentUser.GetProfileField("chat_moderation_rules"); len(v) > 0 {
// Specific mod rules applied to the current user.
rules = strings.Split(v, ",")
}
// Create the JWT claims.
claims := Claims{
IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator),
VIP: currentUser.IsInnerCircle(),
Avatar: avatar,
ProfileURL: "/u/" + currentUser.Username,
Nickname: currentUser.NameOrUsername(),
Emoji: emoji,
Gender: Gender(currentUser),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: config.Title,
Subject: currentUser.Username,
ID: fmt.Sprintf("%d", currentUser.ID),
},
IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator),
Avatar: avatar,
ProfileURL: "/u/" + currentUser.Username,
Nickname: currentUser.NameOrUsername(),
Emoji: emoji,
Gender: Gender(currentUser),
VIP: isShy, // "shy accounts" use the "VIP" status for special icon in chat
Rules: rules,
RegisteredClaims: encryption.StandardClaims(currentUser.ID, currentUser.Username, time.Now().Add(5*time.Minute)),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
ss, err := token.SignedString(secret)
token, err := encryption.SignClaims(claims, []byte(config.Current.BareRTC.JWTSecret))
if err != nil {
session.FlashError(w, r, "Couldn't sign you into the chat: %s", err)
templates.Redirect(w, r.URL.Path)
@ -153,8 +145,19 @@ func Landing() http.HandlerFunc {
log.Error("SendBlocklist: %s", err)
}
// Mark them as online immediately: so e.g. on the Change Username screen we leave no window
// of time where they can exist in chat but change their name on the site.
worker.GetChatStatistics().SetOnlineNow(currentUser.Username)
// Ping their chat login usage statistic.
go func() {
if err := models.LogDailyChatUser(currentUser); err != nil {
log.Error("LogDailyChatUser(%s): error logging this user's chat statistic: %s", currentUser.Username, err)
}
}()
// Redirect them to the chat room.
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss)
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+token)
return
}

View File

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

View File

@ -28,12 +28,26 @@ func Subscription() http.HandlerFunc {
templates.Redirect(w, "/")
return
} else {
if idInt, err := strconv.Atoi(idStr); err != nil {
session.FlashError(w, r, "Comment table ID invalid.")
templates.Redirect(w, "/")
return
} else {
tableID = uint64(idInt)
// Is the table_id expected to be a username?
switch tableName {
case "friend.photos":
// Special "Friend uploaded a new photo" opt-out.
if user, err := models.FindUser(idStr); err != nil {
session.FlashError(w, r, "Username not found!")
templates.Redirect(w, "/")
return
} else {
tableID = user.ID
}
default:
// Integer IDs in all other cases.
if idInt, err := strconv.Atoi(idStr); err != nil {
session.FlashError(w, r, "Comment table ID invalid.")
templates.Redirect(w, "/")
return
} else {
tableID = uint64(idInt)
}
}
}
@ -47,7 +61,7 @@ func Subscription() http.HandlerFunc {
}
// Validate everything else.
if _, ok := models.CommentableTables[tableName]; !ok {
if _, ok := models.SubscribableTables[tableName]; !ok {
session.FlashError(w, r, "You can not comment on that.")
templates.Redirect(w, "/")
return
@ -61,6 +75,12 @@ func Subscription() http.HandlerFunc {
return
}
// Language to use in the flash messages.
var kind = "comments"
if tableName == "friend.photos" {
kind = "new photo uploads"
}
// Get their subscription.
sub, err := models.GetSubscription(currentUser, tableName, tableID)
if err != nil {
@ -69,7 +89,15 @@ func Subscription() http.HandlerFunc {
if _, err := models.SubscribeTo(currentUser, tableName, tableID); err != nil {
session.FlashError(w, r, "Couldn't create subscription: %s", err)
} else {
session.Flash(w, r, "You will now be notified about comments on this page.")
session.Flash(w, r, "You will now be notified about %s on this page.", kind)
}
} else {
// An explicit subscribe=false, may be a preemptive opt-out as in
// friend new photo notifications.
if _, err := models.UnsubscribeTo(currentUser, tableName, tableID); err != nil {
session.FlashError(w, r, "Couldn't create subscription: %s", err)
} else {
session.Flash(w, r, "You will no longer be notified about %s on this page.", kind)
}
}
} else {
@ -79,9 +107,9 @@ func Subscription() http.HandlerFunc {
session.FlashError(w, r, "Couldn't save your subscription settings: %s", err)
} else {
if subscribe {
session.Flash(w, r, "You will now be notified about comments on this page.")
session.Flash(w, r, "You will now be notified about %s on this page.", kind)
} else {
session.Flash(w, r, "You will no longer be notified about new comments on this page.")
session.Flash(w, r, "You will no longer be notified about new %s on this page.", kind)
}
}
}

View File

@ -1,6 +1,7 @@
package forum
import (
"fmt"
"net/http"
"strconv"
"strings"
@ -44,7 +45,7 @@ func AddEdit() http.HandlerFunc {
return
} else {
// Do we have permission?
if found.OwnerID != currentUser.ID && !currentUser.IsAdmin {
if !found.CanEdit(currentUser) {
templates.ForbiddenPage(w, r)
return
}
@ -53,6 +54,13 @@ func AddEdit() http.HandlerFunc {
}
}
// If we are over our quota for User Forums, do not allow creating a new one.
if forum == nil && !currentUser.HasAdminScope(config.ScopeForumAdmin) && currentUser.ForumQuotaRemaining() <= 0 {
session.FlashError(w, r, "You do not currently have spare quota to create a new forum.")
templates.Redirect(w, "/forum/admin")
return
}
// Saving?
if r.Method == http.MethodPost {
var (
@ -63,29 +71,49 @@ func AddEdit() http.HandlerFunc {
isExplicit = r.PostFormValue("explicit") == "true"
isPrivileged = r.PostFormValue("privileged") == "true"
isPermitPhotos = r.PostFormValue("permit_photos") == "true"
isInnerCircle = r.PostFormValue("inner_circle") == "true"
isPrivate = r.PostFormValue("private") == "true"
)
// Sanity check admin-only settings.
if !currentUser.IsAdmin {
// Sanity check admin-only settings -> default these to OFF.
if !currentUser.HasAdminScope(config.ScopeForumAdmin) {
isPrivileged = false
isPermitPhotos = false
isPrivate = false
}
// Were we editing an existing forum?
if forum != nil {
diffs := []models.FieldDiff{
models.NewFieldDiff("Title", forum.Title, title),
models.NewFieldDiff("Description", forum.Description, description),
models.NewFieldDiff("Category", forum.Category, category),
models.NewFieldDiff("Explicit", forum.Explicit, isExplicit),
models.NewFieldDiff("PermitPhotos", forum.PermitPhotos, isPermitPhotos),
}
forum.Title = title
forum.Description = description
forum.Category = category
forum.Explicit = isExplicit
forum.Privileged = isPrivileged
forum.PermitPhotos = isPermitPhotos
forum.InnerCircle = isInnerCircle
// Forum Admin-only options: if the current viewer is not a forum admin, do not change these settings.
// e.g.: the front-end checkboxes are hidden and don't want to accidentally unset these!
if currentUser.HasAdminScope(config.ScopeForumAdmin) {
diffs = append(diffs,
models.NewFieldDiff("Privileged", forum.Privileged, isPrivileged),
models.NewFieldDiff("Private", forum.Private, isPrivate),
)
forum.Privileged = isPrivileged
forum.Private = isPrivate
}
// Save it.
if err := forum.Save(); err == nil {
session.Flash(w, r, "Forum has been updated!")
templates.Redirect(w, "/forum/admin")
// Log the change.
models.LogUpdated(currentUser, nil, "forums", forum.ID, "Updated the forum's settings.", diffs)
return
} else {
session.FlashError(w, r, "Error saving the forum: %s", err)
@ -113,12 +141,39 @@ func AddEdit() http.HandlerFunc {
Explicit: isExplicit,
Privileged: isPrivileged,
PermitPhotos: isPermitPhotos,
InnerCircle: isInnerCircle,
Private: isPrivate,
}
if err := models.CreateForum(forum); err == nil {
session.Flash(w, r, "The forum has been created!")
templates.Redirect(w, "/forum/admin")
// Log the change.
models.LogCreated(currentUser, "forums", forum.ID, fmt.Sprintf(
"Created a new forum.\n\n"+
"* Category: %s\n"+
"* Title: %s\n"+
"* Fragment: %s\n"+
"* Description: %s\n"+
"* Explicit: %v\n"+
"* Privileged: %v\n"+
"* Photos: %v\n"+
"* Private: %v",
forum.Category,
forum.Title,
forum.Fragment,
forum.Description,
forum.Explicit,
forum.Privileged,
forum.PermitPhotos,
forum.Private,
))
// If this is a Community forum, subscribe the owner to it immediately.
if forum.Category == "" {
models.CreateForumMembership(currentUser, forum)
}
return
} else {
session.FlashError(w, r, "Error creating the forum: %s", err)
@ -127,12 +182,20 @@ func AddEdit() http.HandlerFunc {
}
}
_ = editID
// Get the list of moderators.
var mods []*models.User
if forum != nil {
mods, err = forum.GetModerators()
if err != nil {
session.FlashError(w, r, "Error getting moderators list: %s", err)
}
}
var vars = map[string]interface{}{
"EditID": editID,
"EditForum": forum,
"Categories": config.ForumCategories,
"Moderators": mods,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -0,0 +1,118 @@
package forum
import (
"net/http"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// Explore all existing forums.
func Explore() http.HandlerFunc {
// This page shares a template with the board index (Categories) page.
tmpl := templates.Must("forum/index.html")
// Whitelist for ordering options.
var sortWhitelist = []string{
"created_at desc",
"created_at asc",
"title asc",
"title desc",
// Special sort handlers.
// See PaginateForums for expanded handlers for these.
"by_followers",
"by_latest",
"by_threads",
"by_posts",
"by_users",
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
searchTerm = r.FormValue("q")
search = models.ParseSearchString(searchTerm)
show = r.FormValue("show")
categories = []string{}
subscribed = r.FormValue("show") == "followed"
sort = r.FormValue("sort")
sortOK bool
)
// Sort options.
for _, v := range sortWhitelist {
if sort == v {
sortOK = true
break
}
}
if !sortOK {
sort = sortWhitelist[0]
}
// Set of forum categories to filter for.
switch show {
case "official":
categories = config.ForumCategories
case "community":
categories = []string{""}
}
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Couldn't get current user: %s", err)
templates.Redirect(w, "/")
return
}
var pager = &models.Pagination{
Page: 1,
PerPage: config.PageSizeBrowseForums,
Sort: sort,
}
pager.ParsePage(r)
// Browse all forums (no category filter for official)
forums, err := models.PaginateForums(currentUser, categories, search, subscribed, pager)
if err != nil {
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
templates.Redirect(w, "/")
return
}
// Bucket the forums into their categories for easy front-end.
categorized := models.CategorizeForums(forums, nil)
// Map statistics for these forums.
forumMap := models.MapForumStatistics(forums)
followMap := models.MapForumMemberships(currentUser, forums)
var vars = map[string]interface{}{
"CurrentForumTab": "explore",
"IsExploreTab": true,
"Pager": pager,
"Categories": categorized,
"ForumMap": forumMap,
"FollowMap": followMap,
"FollowersMap": models.MapForumFollowers(forums),
// Search filters
"SearchTerm": searchTerm,
"Show": show,
"Sort": sort,
// Current viewer's forum quota.
"ForumQuota": models.ComputeForumQuota(currentUser),
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -16,21 +16,16 @@ func Forum() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the path parameters
var (
forum *models.Forum
fragment = r.PathValue("fragment")
forum *models.Forum
)
if m := ForumPathRegexp.FindStringSubmatch(r.URL.Path); m == nil {
log.Error("Regexp failed to parse: %s", r.URL.Path)
// Look up the forum by its fragment.
if found, err := models.ForumByFragment(fragment); err != nil {
templates.NotFoundPage(w, r)
return
} else {
// Look up the forum itself.
if found, err := models.ForumByFragment(m[1]); err != nil {
templates.NotFoundPage(w, r)
return
} else {
forum = found
}
forum = found
}
// Get the current user.
@ -41,8 +36,8 @@ func Forum() http.HandlerFunc {
return
}
// Is it an inner circle forum?
if forum.InnerCircle && !currentUser.IsInnerCircle() {
// Is it a private forum?
if !forum.CanBeSeenBy(currentUser) {
templates.NotFoundPage(w, r)
return
}
@ -60,7 +55,7 @@ func Forum() http.HandlerFunc {
var pager = &models.Pagination{
Page: 1,
PerPage: config.PageSizeThreadList,
Sort: "updated_at desc",
Sort: "threads.updated_at desc",
}
pager.ParsePage(r)
@ -71,17 +66,28 @@ func Forum() http.HandlerFunc {
return
}
// Inject pinned threads on top.
threads = append(pinned, threads...)
// Inject pinned threads on top of the first page.
if pager.Page == 1 {
threads = append(pinned, threads...)
}
// Map the statistics (replies, views) of these threads.
threadMap := models.MapThreadStatistics(threads)
// Load the forum's moderators.
mods, err := forum.GetModerators()
if err != nil {
log.Error("Getting forum moderators: %s", err)
}
var vars = map[string]interface{}{
"Forum": forum,
"Threads": threads,
"ThreadMap": threadMap,
"Pager": pager,
"Forum": forum,
"ForumModerators": mods,
"ForumSubscriberCount": models.CountForumMemberships(forum),
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
"Threads": threads,
"ThreadMap": threadMap,
"Pager": pager,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

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.
@ -44,14 +33,13 @@ func Landing() http.HandlerFunc {
// Get all the categorized index forums.
// XXX: we get a large page size to get ALL official forums
var pager = &models.Pagination{
// This pager is hardcoded and doesn't parse from ?page= params.
var indexPager = &models.Pagination{
Page: 1,
PerPage: config.PageSizeForums,
Sort: "title asc",
}
pager.ParsePage(r)
forums, err := models.PaginateForums(currentUser, config.ForumCategories, pager)
forums, err := models.PaginateForums(currentUser, config.ForumCategories, nil, false, indexPager)
if err != nil {
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
templates.Redirect(w, "/")
@ -61,13 +49,41 @@ func Landing() http.HandlerFunc {
// Bucket the forums into their categories for easy front-end.
categorized := models.CategorizeForums(forums, config.ForumCategories)
// Inject the "My List" Category if the user subscribes to forums.
var pager = &models.Pagination{
Page: 1,
PerPage: config.PageSizeMyListForums,
Sort: "by_latest",
}
pager.ParsePage(r)
if config.UserForumsEnabled {
myList, err := models.PaginateForums(currentUser, nil, nil, true, pager)
if err != nil {
session.FlashError(w, r, "Couldn't get your followed forums: %s", err)
} else if len(myList) > 0 {
forums = append(forums, myList...)
categorized = append([]*models.CategorizedForum{
{
Category: "My List",
Forums: myList,
},
}, categorized...)
}
}
// Map statistics for these forums.
forumMap := models.MapForumStatistics(forums)
followMap := models.MapForumMemberships(currentUser, forums)
var vars = map[string]interface{}{
"Pager": pager,
"Categories": categorized,
"ForumMap": forumMap,
"Pager": pager,
"Categories": categorized,
"ForumMap": forumMap,
"FollowMap": followMap,
"FollowersMap": models.MapForumFollowers(forums),
// Current viewer's forum quota.
"ForumQuota": models.ComputeForumQuota(currentUser),
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -12,7 +12,40 @@ import (
// Manage page for forums -- admin only for now but may open up later.
func Manage() http.HandlerFunc {
tmpl := templates.Must("forum/admin.html")
// Whitelist for ordering options.
var sortWhitelist = []string{
"updated_at desc",
"created_at desc",
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
searchTerm = r.FormValue("q")
show = r.FormValue("show")
categories = []string{}
sort = r.FormValue("sort")
sortOK bool
)
// Sort options.
for _, v := range sortWhitelist {
if sort == v {
sortOK = true
break
}
}
if !sortOK {
sort = sortWhitelist[0]
}
// Show options.
if show == "official" {
categories = config.ForumCategories
} else if show == "community" {
categories = []string{""}
}
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
@ -21,15 +54,24 @@ func Manage() http.HandlerFunc {
return
}
// Parse their search term.
var search = models.ParseSearchString(searchTerm)
// Get forums the user owns or can manage.
var pager = &models.Pagination{
Page: 1,
PerPage: config.PageSizeForumAdmin,
Sort: "updated_at desc",
Sort: sort,
}
pager.ParsePage(r)
forums, err := models.PaginateOwnedForums(currentUser.ID, currentUser.IsAdmin, pager)
forums, err := models.PaginateOwnedForums(
currentUser.ID,
currentUser.HasAdminScope(config.ScopeForumAdmin),
categories,
search,
pager,
)
if err != nil {
session.FlashError(w, r, "Couldn't paginate owned forums: %s", err)
templates.Redirect(w, "/")
@ -39,6 +81,15 @@ func Manage() http.HandlerFunc {
var vars = map[string]interface{}{
"Pager": pager,
"Forums": forums,
// Quote settings.
"QuotaLimit": models.ComputeForumQuota(currentUser),
"QuotaCount": models.CountOwnedUserForums(currentUser),
// Search filters.
"SearchTerm": searchTerm,
"Show": show,
"Sort": sort,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -0,0 +1,227 @@
package forum
import (
"fmt"
"net/http"
"strconv"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// ManageModerators controller (/forum/admin/moderators) to appoint moderators to your (user) forum.
func ManageModerators() http.HandlerFunc {
// Reuse the upload page but with an EditPhoto variable.
tmpl := templates.Must("forum/moderators.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
intent = r.FormValue("intent")
stringID = r.FormValue("forum_id")
)
// Parse forum_id query parameter.
var forumID uint64
if stringID != "" {
if i, err := strconv.Atoi(stringID); err == nil {
forumID = uint64(i)
} else {
session.FlashError(w, r, "Edit parameter: forum_id was not an integer")
templates.Redirect(w, "/forum/admin")
return
}
}
// Redirect URLs
var (
next = fmt.Sprintf("%s?forum_id=%d", r.URL.Path, forumID)
nextFinished = fmt.Sprintf("/forum/admin/edit?id=%d", forumID)
)
// Load the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: could not get currentUser.")
templates.Redirect(w, "/")
return
}
// Are we adding/removing a user as moderator?
var (
username = r.FormValue("to")
user *models.User
)
if username != "" {
if found, err := models.FindUser(username); err != nil {
templates.NotFoundPage(w, r)
return
} else {
user = found
}
}
// Look up the forum by its fragment.
forum, err := models.GetForum(forumID)
if err != nil {
templates.NotFoundPage(w, r)
return
}
// User must be the owner of this forum, or a privileged admin.
if !forum.CanEdit(currentUser) {
templates.ForbiddenPage(w, r)
return
}
// The forum owner can not add themself.
if user != nil && forum.OwnerID == user.ID {
session.FlashError(w, r, "You can not add the forum owner to its moderators list.")
templates.Redirect(w, next)
return
}
// POSTing?
if r.Method == http.MethodPost {
switch intent {
case "submit":
// Confirmed adding a moderator.
if _, err := forum.AddModerator(user); err != nil {
session.FlashError(w, r, "Error adding the moderator: %s", err)
templates.Redirect(w, next)
return
}
// Create a notification for this.
notif := &models.Notification{
UserID: user.ID,
AboutUser: *currentUser,
Type: models.NotificationForumModerator,
TableName: "forums",
TableID: forum.ID,
Link: fmt.Sprintf("/f/%s", forum.Fragment),
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create PrivatePhoto notification: %s", err)
}
session.Flash(w, r, "%s has been added to the moderators list!", user.Username)
templates.Redirect(w, nextFinished)
return
case "confirm-remove":
// Confirm removing a moderator.
if _, err := forum.RemoveModerator(user); err != nil {
session.FlashError(w, r, "Error removing the moderator: %s", err)
templates.Redirect(w, next)
return
}
// Revoke any past notifications they had about being added as moderator.
if err := models.RemoveSpecificNotification(user.ID, models.NotificationForumModerator, "forums", forum.ID); err != nil {
log.Error("Couldn't revoke the forum moderator notification: %s", err)
}
session.Flash(w, r, "%s has been removed from the moderators list.", user.Username)
templates.Redirect(w, nextFinished)
return
}
}
var vars = map[string]interface{}{
"Forum": forum,
"User": user,
"IsRemoving": intent == "remove",
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
// ModerateThread endpoint - perform a mod action like pinning or locking a thread.
func ModerateThread() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query params.
var (
threadID, err = strconv.Atoi(r.PathValue("id"))
intent = r.PostFormValue("intent")
nextURL = fmt.Sprintf("/forum/thread/%d", threadID)
)
if err != nil {
session.FlashError(w, r, "Invalid thread ID.")
templates.Redirect(w, nextURL)
return
}
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Couldn't get current user: %s", err)
templates.Redirect(w, "/")
return
}
// Get this thread.
thread, err := models.GetThread(uint64(threadID))
if err != nil {
templates.NotFoundPage(w, r)
return
}
// Get its forum.
forum, err := models.GetForum(thread.ForumID)
if err != nil {
templates.NotFoundPage(w, r)
return
}
// User must at least be able to moderate.
if !forum.CanBeModeratedBy(currentUser) {
templates.ForbiddenPage(w, r)
return
}
// Does the user have Ownership level access (including privileged admins)
var isOwner = forum.OwnerID == currentUser.ID || currentUser.HasAdminScope(config.ScopeForumAdmin)
/****
* Moderator level permissions.
***/
switch intent {
case "lock":
thread.NoReply = true
session.Flash(w, r, "This thread has been locked and will not be accepting any new replies.")
case "unlock":
thread.NoReply = false
session.Flash(w, r, "This thread has been unlocked and can accept new replies again.")
default:
if !isOwner {
// End of the road.
templates.ForbiddenPage(w, r)
return
}
}
/****
* Owner + Admin level permissions.
***/
switch intent {
case "pin":
thread.Pinned = true
session.Flash(w, r, "This thread is now pinned to the top of the forum.")
case "unpin":
thread.Pinned = false
session.Flash(w, r, "This thread will no longer be pinned to the top of the forum.")
}
// Save changes to the thread.
if err := thread.Save(); err != nil {
session.FlashError(w, r, "Error saving thread: %s", err)
}
templates.Redirect(w, nextURL)
})
}

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"
)
@ -86,6 +87,11 @@ func NewPost() http.HandlerFunc {
}
}
// If the current user can moderate the forum thread, e.g. edit or delete posts.
// Admins can edit always, user owners of forums can only delete.
var canModerate = currentUser.HasAdminScope(config.ScopeForumModerator) ||
(forum.OwnerID == currentUser.ID && isDelete)
// Does the comment have an existing Photo ID?
if len(photoID) > 0 {
if i, err := strconv.Atoi(photoID); err == nil {
@ -115,7 +121,7 @@ func NewPost() http.HandlerFunc {
comment = found
// Verify that it is indeed OUR comment.
if currentUser.ID != comment.UserID && !currentUser.HasAdminScope(config.ScopeForumModerator) {
if currentUser.ID != comment.UserID && !canModerate {
templates.ForbiddenPage(w, r)
return
}
@ -161,6 +167,11 @@ func NewPost() http.HandlerFunc {
session.FlashError(w, r, "Error deleting your post: %s", err)
} else {
session.Flash(w, r, "Your post has been deleted.")
// Log the change.
models.LogDeleted(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, fmt.Sprintf(
"Deleted a forum comment on thread %d forum /f/%s", thread.ID, forum.Fragment,
), comment)
}
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return
@ -178,6 +189,19 @@ func NewPost() http.HandlerFunc {
// Submitting the form.
if r.Method == http.MethodPost {
// Look for spammy links to video sites or things.
if err := spam.DetectSpamMessage(title + message); err != nil {
session.FlashError(w, r, err.Error())
if thread != nil {
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
} else if forum != nil {
templates.Redirect(w, fmt.Sprintf("/f/%s", forum.Fragment))
} else {
templates.Redirect(w, "/forum")
}
return
}
// Polls: parse form parameters into a neat list of answers.
pollExpires, _ = strconv.Atoi(r.FormValue("poll_expires"))
var distinctPollChoices = map[string]interface{}{}
@ -315,6 +339,14 @@ func NewPost() http.HandlerFunc {
session.FlashError(w, r, "Couldn't save comment: %s", err)
} else {
session.Flash(w, r, "Comment updated!")
// Log the change.
models.LogUpdated(&models.User{ID: comment.UserID}, currentUser, "comments", comment.ID, fmt.Sprintf(
"Edited their comment on thread %d (in /f/%s):\n\n%s",
thread.ID,
forum.Fragment,
message,
), nil)
}
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return
@ -327,6 +359,13 @@ func NewPost() http.HandlerFunc {
} else {
session.Flash(w, r, "Reply added to the thread!")
// Log the change.
models.LogCreated(currentUser, "comments", reply.ID, fmt.Sprintf(
"Commented on thread %d:\n\n%s",
thread.ID,
message,
))
// If we're attaching a photo, link it to this reply CommentID.
if commentPhoto != nil {
commentPhoto.CommentID = reply.ID
@ -358,7 +397,7 @@ func NewPost() http.HandlerFunc {
TableName: "threads",
TableID: thread.ID,
Message: message,
Link: fmt.Sprintf("/forum/thread/%d%s#p%d", thread.ID, queryString, reply.ID),
Link: fmt.Sprintf("/go/comment?id=%d", reply.ID),
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create thread reply notification for subscriber %d: %s", userID, err)
@ -428,6 +467,18 @@ func NewPost() http.HandlerFunc {
}
}
// Log the change.
models.LogCreated(currentUser, "threads", thread.ID, fmt.Sprintf(
"Started a new forum thread on forum /f/%s (%s)\n\n"+
"* Has poll? %v\n"+
"* Title: %s\n\n%s",
forum.Fragment,
forum.Title,
isPoll,
thread.Title,
message,
))
templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID))
return
}

View File

@ -14,6 +14,14 @@ import (
func Newest() http.HandlerFunc {
tmpl := templates.Must("forum/newest.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query parameters.
var (
allComments = r.FormValue("all") == "true"
whichForums = r.FormValue("which")
categories = []string{}
subscribed bool
)
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
@ -22,6 +30,29 @@ func Newest() http.HandlerFunc {
return
}
// Recall the user's default "Which forum:" answer if not selected.
if whichForums == "" {
whichForums = currentUser.GetProfileField("forum_newest_default")
if whichForums == "" {
whichForums = "official"
}
}
// Narrow down to which set of forums?
switch whichForums {
case "official":
categories = config.ForumCategories
case "community":
categories = []string{""}
case "followed":
subscribed = true
default:
whichForums = "all"
}
// Store their "Which forums" filter to be their new default view.
currentUser.SetProfileField("forum_newest_default", whichForums)
// Get all the categorized index forums.
var pager = &models.Pagination{
Page: 1,
@ -29,7 +60,7 @@ func Newest() http.HandlerFunc {
}
pager.ParsePage(r)
posts, err := models.PaginateRecentPosts(currentUser, config.ForumCategories, pager)
posts, err := models.PaginateRecentPosts(currentUser, categories, subscribed, allComments, pager)
if err != nil {
session.FlashError(w, r, "Couldn't paginate forums: %s", err)
templates.Redirect(w, "/")
@ -47,9 +78,14 @@ func Newest() http.HandlerFunc {
}
var vars = map[string]interface{}{
"Pager": pager,
"RecentPosts": posts,
"PhotoMap": photos,
"CurrentForumTab": "newest",
"Pager": pager,
"RecentPosts": posts,
"PhotoMap": photos,
// Filter options.
"WhichForums": whichForums,
"AllComments": allComments,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -25,6 +25,8 @@ func Search() http.HandlerFunc {
searchTerm = r.FormValue("q")
byUsername = r.FormValue("username")
postType = r.FormValue("type")
inForum = r.FormValue("in")
categories = []string{}
sort = r.FormValue("sort")
sortOK bool
)
@ -45,6 +47,14 @@ func Search() http.HandlerFunc {
postType = "all"
}
// In forums
switch inForum {
case "official":
categories = config.ForumCategories
case "community":
categories = []string{""}
}
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
@ -80,7 +90,7 @@ func Search() http.HandlerFunc {
)
pager.ParsePage(r)
posts, err := models.SearchForum(currentUser, search, filters, pager)
posts, err := models.SearchForum(currentUser, categories, search, filters, pager)
if err != nil {
session.FlashError(w, r, "Couldn't search the forums: %s", err)
templates.Redirect(w, "/")
@ -100,14 +110,16 @@ func Search() http.HandlerFunc {
}
var vars = map[string]interface{}{
"Pager": pager,
"Comments": posts,
"ThreadMap": threadMap,
"PhotoMap": photos,
"CurrentForumTab": "search",
"Pager": pager,
"Comments": posts,
"ThreadMap": threadMap,
"PhotoMap": photos,
"SearchTerm": searchTerm,
"ByUsername": byUsername,
"Type": postType,
"InForum": inForum,
"Sort": sort,
}
if err := tmpl.Execute(w, r, vars); err != nil {

View File

@ -0,0 +1,75 @@
package forum
import (
"net/http"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// Subscribe to a forum, adding it to your bookmark list.
func Subscribe() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Parse the path parameters
var (
fragment = r.FormValue("fragment")
forum *models.Forum
intent = r.FormValue("intent")
)
// Look up the forum by its fragment.
if found, err := models.ForumByFragment(fragment); err != nil {
templates.NotFoundPage(w, r)
return
} else {
forum = found
}
// Get the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Couldn't get current user: %s", err)
templates.Redirect(w, "/")
return
}
switch intent {
case "follow":
// Is it a private forum?
if forum.Private && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
return
}
_, err := models.CreateForumMembership(currentUser, forum)
if err != nil {
session.FlashError(w, r, "Couldn't follow this forum: %s", err)
} else {
session.Flash(w, r, "You have added %s to your forum list.", forum.Title)
}
case "unfollow":
fm, err := models.GetForumMembership(currentUser, forum)
if err == nil {
// Were we a moderator previously? If so, revoke the notification about it.
if fm.IsModerator {
if err := models.RemoveSpecificNotification(currentUser.ID, models.NotificationForumModerator, "forums", forum.ID); err != nil {
log.Error("User unsubscribed from forum and couldn't remove their moderator notification: %s", err)
}
}
err = fm.Delete()
if err != nil {
session.FlashError(w, r, "Couldn't delete your forum membership: %s", err)
}
}
session.Flash(w, r, "You have removed %s from your forum list.", forum.Title)
default:
session.Flash(w, r, "Unknown intent.")
}
templates.Redirect(w, "/f/"+fragment)
})
}

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
@ -54,12 +51,16 @@ func Thread() http.HandlerFunc {
return
}
// Is it an inner circle forum?
if forum.InnerCircle && !currentUser.IsInnerCircle() {
// Is it a private forum?
if !forum.CanBeSeenBy(currentUser) {
templates.NotFoundPage(w, r)
return
}
// Can we moderate this forum? (from a user-owned forum perspective,
// e.g. can we delete threads and posts, not edit them)
var canModerate = forum.CanBeModeratedBy(currentUser)
// Ping the view count on this thread.
if err := thread.View(currentUser.ID); err != nil {
log.Error("Couldn't ping view count on thread %d: %s", thread.ID, err)
@ -73,7 +74,7 @@ func Thread() http.HandlerFunc {
}
pager.ParsePage(r)
comments, err := models.PaginateComments(currentUser, "threads", thread.ID, pager)
comments, err := models.PaginateComments(currentUser, "threads", thread.ID, canModerate, pager)
if err != nil {
session.FlashError(w, r, "Couldn't paginate comments: %s", err)
templates.Redirect(w, "/")
@ -96,14 +97,23 @@ func Thread() http.HandlerFunc {
// Is the current user subscribed to notifications on this thread?
_, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID)
// Ping this user as having used the forums today.
go func() {
if err := models.LogDailyForumUser(currentUser); err != nil {
log.Error("LogDailyForumUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
}
}()
var vars = map[string]interface{}{
"Forum": forum,
"Thread": thread,
"Comments": comments,
"LikeMap": commentLikeMap,
"PhotoMap": photos,
"Pager": pager,
"IsSubscribed": isSubscribed,
"Forum": forum,
"Thread": thread,
"Comments": comments,
"LikeMap": commentLikeMap,
"PhotoMap": photos,
"Pager": pager,
"CanModerate": canModerate,
"IsSubscribed": isSubscribed,
"IsForumSubscribed": models.IsForumSubscribed(currentUser, forum),
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)

View File

@ -10,6 +10,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
"code.nonshy.com/nonshy/website/pkg/webpush"
)
// AddFriend controller to send a friend request.
@ -60,6 +61,11 @@ func AddFriend() http.HandlerFunc {
return
}
// Revoke any friends-only photo notifications they had received before.
if err := models.RevokeFriendPhotoNotifications(currentUser, user); err != nil {
log.Error("Couldn't revoke friend photo notifications between %s and %s: %s", currentUser.Username, user.Username, err)
}
var message string
if verdict == "reject" {
message = fmt.Sprintf("Friend request from %s has been rejected.", username)
@ -70,6 +76,12 @@ func AddFriend() http.HandlerFunc {
session.Flash(w, r, message)
if verdict == "reject" {
templates.Redirect(w, "/friends?view=requests")
// Log the change.
models.LogDeleted(currentUser, nil, "friends", user.ID, "Rejected friend request from "+user.Username+".", nil)
} else {
// Log the change.
models.LogDeleted(currentUser, nil, "friends", user.ID, "Removed friendship with "+user.Username+".", nil)
}
templates.Redirect(w, "/friends")
return
@ -80,6 +92,9 @@ func AddFriend() http.HandlerFunc {
session.Flash(w, r, "You have ignored the friend request from %s.", username)
}
templates.Redirect(w, "/friends")
// Log the change.
models.LogUpdated(currentUser, nil, "friends", user.ID, "Ignored the friend request from "+user.Username+".", nil)
return
} else {
// Post the friend request.
@ -101,7 +116,28 @@ func AddFriend() http.HandlerFunc {
session.Flash(w, r, "You accepted the friend request from %s!", username)
templates.Redirect(w, "/friends?view=requests")
// Log the change.
models.LogUpdated(currentUser, nil, "friends", user.ID, "Accepted friend request from "+user.Username+".", nil)
return
} else {
// Log the change.
models.LogCreated(currentUser, "friends", user.ID, "Sent a friend request to "+user.Username+".")
// Send a push notification to the recipient.
go func() {
// Opted out of this one?
if user.GetProfileField(config.PushNotificationOptOutFriends) == "true" {
return
}
log.Info("Try and send Web Push notification about new Friend Request to: %s", user.Username)
webpush.SendNotification(user, webpush.Payload{
Topic: "friend",
Title: "New Friend Request!",
Body: fmt.Sprintf("%s wants to be your friend on %s.", currentUser.Username, config.Title),
})
}()
}
session.Flash(w, r, "Friend request sent!")
}

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

@ -4,9 +4,12 @@ import (
"fmt"
"net/http"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
"code.nonshy.com/nonshy/website/pkg/webpush"
)
// Compose a new chat coming from a user's profile page.
@ -61,9 +64,25 @@ func Compose() http.HandlerFunc {
return
}
// Send a push notification to the recipient.
go func() {
// Opted out of this one?
if user.GetProfileField(config.PushNotificationOptOutMessage) == "true" {
return
}
log.Info("Try and send Web Push notification about new Message to: %s", user.Username)
webpush.SendNotification(user, webpush.Payload{
Topic: "inbox",
Title: "New Message!",
Body: fmt.Sprintf("%s has left you a message on %s.", currentUser.Username, config.Title),
})
}()
session.Flash(w, r, "Your message has been delivered!")
if from == "inbox" {
templates.Redirect(w, fmt.Sprintf("/messages/read/%d", m.ID))
return
}
templates.Redirect(w, "/messages")
return

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

@ -26,13 +26,15 @@ func Contact() http.HandlerFunc {
subject = r.FormValue("subject")
title = "Contact Us"
message = r.FormValue("message")
footer string // appends to the message only when posting the feedback
replyTo = r.FormValue("email")
trap1 = r.FormValue("url") != "https://"
trap2 = r.FormValue("comment") != ""
tableID int
tableName string
tableLabel string // front-end user feedback about selected report item
messageRequired = true // unless we have a table ID to work with
tableLabel string // front-end user feedback about selected report item
aboutUser *models.User // associated user (e.g. owner of reported photo)
messageRequired = true // unless we have a table ID to work with
success = "Thank you for your feedback! Your message has been delivered to the website administrators."
)
@ -55,6 +57,7 @@ func Contact() http.HandlerFunc {
tableName = "users"
if user, err := models.GetUser(uint64(tableID)); err == nil {
tableLabel = fmt.Sprintf(`User account "%s"`, user.Username)
aboutUser = user
} else {
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
}
@ -65,6 +68,7 @@ func Contact() http.HandlerFunc {
if pic, err := models.GetPhoto(uint64(tableID)); err == nil {
if user, err := models.GetUser(pic.UserID); err == nil {
tableLabel = fmt.Sprintf(`A profile photo of user account "%s"`, user.Username)
aboutUser = user
} else {
log.Error("/contact: couldn't produce table label for user %d: %s", tableID, err)
}
@ -74,12 +78,42 @@ func Contact() http.HandlerFunc {
case "report.message":
tableName = "messages"
tableLabel = "Direct Message conversation"
// Find this message, and attach it to the report.
if msg, err := models.GetMessage(uint64(tableID)); err == nil {
var username = "[unavailable]"
if sender, err := models.GetUser(msg.SourceUserID); err == nil {
username = sender.Username
aboutUser = sender
}
footer = fmt.Sprintf(`
---
From: <a href="/u/%s">@%s</a>
%s`,
username, username,
markdown.Quotify(msg.Message),
)
}
case "report.comment":
tableName = "comments"
// Find this comment.
if comment, err := models.GetComment(uint64(tableID)); err == nil {
tableLabel = fmt.Sprintf(`A comment written by "%s"`, comment.User.Username)
aboutUser = &comment.User
} else {
log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err)
}
case "report.forum", "forum.adopt":
tableName = "forums"
// Find this forum.
if forum, err := models.GetForum(uint64(tableID)); err == nil {
tableLabel = fmt.Sprintf(`The forum "%s" (/f/%s)`, forum.Title, forum.Fragment)
} else {
log.Error("/contact: couldn't produce table label for comment %d: %s", tableID, err)
}
@ -132,11 +166,15 @@ func Contact() http.HandlerFunc {
fb := &models.Feedback{
Intent: intent,
Subject: subject,
Message: message,
Message: message + footer,
TableName: tableName,
TableID: uint64(tableID),
}
if aboutUser != nil {
fb.AboutUserID = aboutUser.ID
}
if currentUser != nil && currentUser.ID > 0 {
fb.UserID = currentUser.ID
} else if replyTo != "" {

View File

@ -0,0 +1,57 @@
package index
import (
"net/http"
"code.nonshy.com/nonshy/website/pkg/models/demographic"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// Demographics page (/insights) to show a peek at website demographics.
func Demographics() http.HandlerFunc {
tmpl := templates.Must("demographics.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
refresh = r.FormValue("refresh") == "true"
)
// Are we refreshing? Check if an admin is logged in.
if refresh {
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "You must be logged in to do that!")
templates.Redirect(w, r.URL.Path)
return
}
// Do the refresh?
if currentUser.IsAdmin {
_, err := demographic.Refresh()
if err != nil {
session.FlashError(w, r, "Refreshing the insights: %s", err)
}
}
templates.Redirect(w, r.URL.Path)
return
}
// Get website statistics to show on home page.
demo, err := demographic.Get()
if err != nil {
session.FlashError(w, r, "Couldn't get website statistics: %s", err)
templates.Redirect(w, "/")
return
}
vars := map[string]interface{}{
"Demographic": demo,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -5,6 +5,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models/demographic"
"code.nonshy.com/nonshy/website/pkg/templates"
)
@ -18,7 +19,17 @@ func Create() http.HandlerFunc {
return
}
if err := tmpl.Execute(w, r, nil); err != nil {
// Get website statistics to show on home page.
demo, err := demographic.Get()
if err != nil {
log.Error("demographic.Get: %s", err)
}
vars := map[string]interface{}{
"Demographic": demo,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@ -38,3 +49,12 @@ func Manifest() http.HandlerFunc {
http.ServeFile(w, r, config.StaticPath+"/manifest.json")
})
}
// Service Worker for web push.
func ServiceWorker() http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/javascript; charset=UTF-8")
w.Header().Add("Service-Worker-Allowed", "/")
http.ServeFile(w, r, config.StaticPath+"/js/service-worker.js")
})
}

View File

@ -0,0 +1,235 @@
package photo
import (
"fmt"
"net/http"
"strconv"
"code.nonshy.com/nonshy/website/pkg/chat"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
pphoto "code.nonshy.com/nonshy/website/pkg/photo"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// BatchEdit controller (/photo/batch-edit?id=N) to change properties about your picture.
func BatchEdit() http.HandlerFunc {
tmpl := templates.Must("photo/batch_edit.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
// Form params
intent = r.FormValue("intent")
photoIDs []uint64
)
// Collect the photo ID params.
if value, ok := r.Form["id"]; ok {
for _, idStr := range value {
if photoID, err := strconv.Atoi(idStr); err == nil {
photoIDs = append(photoIDs, uint64(photoID))
} else {
log.Error("parsing photo ID %s: %s", idStr, err)
}
}
}
// Validation.
if len(photoIDs) == 0 || len(photoIDs) > 100 {
session.FlashError(w, r, "Invalid number of photo IDs.")
templates.Redirect(w, "/")
return
}
// Find these photos by ID.
photos, err := models.GetPhotos(photoIDs)
if err != nil {
templates.NotFoundPage(w, r)
return
}
// Load the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
templates.Redirect(w, "/")
return
}
// Validate permission to edit all of these photos.
var (
ownerIDs []uint64
)
for _, photo := range photos {
if !photo.CanBeEditedBy(currentUser) {
templates.ForbiddenPage(w, r)
return
}
ownerIDs = append(ownerIDs, photo.UserID)
}
// Load the photo owners.
var (
owners, _ = models.MapUsers(currentUser, ownerIDs)
wasShy = map[uint64]bool{} // record if this change may make them shy
redirectURI = "/" // go first owner's gallery
// Are any of them a user's profile photo? (map userID->true) so we know
// who to unlink the picture from first and avoid a postgres error.
wasUserProfilePicture = map[uint64]bool{}
)
for _, user := range owners {
redirectURI = fmt.Sprintf("/u/%s/photos", user.Username)
wasShy[user.ID] = user.IsShy()
// Check if this user's profile ID is being deleted.
if user.ProfilePhotoID != nil {
if _, ok := photos[*user.ProfilePhotoID]; ok {
wasUserProfilePicture[user.ID] = true
}
}
}
// Confirm batch deletion or edit.
if r.Method == http.MethodPost {
confirm := r.PostFormValue("confirm") == "true"
if !confirm {
session.FlashError(w, r, "Confirm you want to modify this photo.")
templates.Redirect(w, redirectURI)
return
}
// Which intent are they executing on?
switch intent {
case "delete":
batchDeletePhotos(w, r, currentUser, photos, wasUserProfilePicture, owners, redirectURI)
case "visibility":
batchUpdateVisibility(w, r, currentUser, photos, owners)
default:
session.FlashError(w, r, "Unknown intent")
}
// Maybe kick them from chat if this deletion makes them into a Shy Account.
for _, user := range owners {
user.FlushCaches()
if !wasShy[user.ID] && user.IsShy() {
if _, err := chat.MaybeDisconnectUser(user); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
}
}
}
// Return the user to their gallery.
templates.Redirect(w, redirectURI)
return
}
var vars = map[string]interface{}{
"Intent": intent,
"Photos": photos,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}
// Batch DELETE executive handler.
func batchDeletePhotos(
w http.ResponseWriter,
r *http.Request,
currentUser *models.User,
photos map[uint64]*models.Photo,
wasUserProfilePicture map[uint64]bool,
owners map[uint64]*models.User,
redirectURI string,
) {
// Delete all the photos.
for _, photo := range photos {
// Was this someone's profile picture ID?
if wasUserProfilePicture[photo.UserID] {
log.Debug("Delete Photo: was the user's profile photo, unset ProfilePhotoID")
if owner, ok := owners[photo.UserID]; ok {
if err := owner.RemoveProfilePhoto(); err != nil {
session.FlashError(w, r, "Error unsetting your current profile photo: %s", err)
templates.Redirect(w, redirectURI)
return
}
}
}
// Remove the images from disk.
for _, filename := range []string{
photo.Filename,
photo.CroppedFilename,
} {
if len(filename) > 0 {
if err := pphoto.Delete(filename); err != nil {
log.Error("Delete Photo: couldn't remove file from disk: %s: %s", filename, err)
}
}
}
// Take back notifications on it.
models.RemoveNotification("photos", photo.ID)
if err := photo.Delete(); err != nil {
session.FlashError(w, r, "Couldn't delete photo: %s", err)
templates.Redirect(w, redirectURI)
return
}
// Log the change.
if owner, ok := owners[photo.UserID]; ok {
models.LogDeleted(owner, currentUser, "photos", photo.ID, "Deleted the photo.", photo)
}
}
session.Flash(w, r, "%d photo(s) deleted!", len(photos))
}
// Batch DELETE executive handler.
func batchUpdateVisibility(
w http.ResponseWriter,
r *http.Request,
currentUser *models.User,
photos map[uint64]*models.Photo,
owners map[uint64]*models.User,
) {
// Visibility setting.
visibility := r.PostFormValue("visibility")
// Delete all the photos.
for _, photo := range photos {
// Diff for the ChangeLog.
diffs := []models.FieldDiff{
models.NewFieldDiff("Visibility", photo.Visibility, visibility),
}
photo.Visibility = models.PhotoVisibility(visibility)
// If going private, take back notifications on it.
if photo.Visibility == models.PhotoPrivate {
models.RemoveNotification("photos", photo.ID)
}
if err := photo.Save(); err != nil {
session.FlashError(w, r, "Error saving photo #%d: %s", photo.ID, err)
}
// Log the change.
if owner, ok := owners[photo.UserID]; ok {
// Log the change.
models.LogUpdated(owner, currentUser, "photos", photo.ID, "Updated the photo's settings.", diffs)
}
}
session.Flash(w, r, "%d photo(s) updated!", len(photos))
}

View File

@ -2,12 +2,16 @@ package photo
import (
"bytes"
"fmt"
"io"
"net/http"
"path/filepath"
"strconv"
"time"
"code.nonshy.com/nonshy/website/pkg/chat"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/encryption/coldstorage"
"code.nonshy.com/nonshy/website/pkg/geoip"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/mail"
@ -70,6 +74,8 @@ func Certification() http.HandlerFunc {
if r.Method == http.MethodPost {
// Are they deleting their photo?
if r.PostFormValue("delete") == "true" {
// Primary cert photo
if cert.Filename != "" {
if err := photo.Delete(cert.Filename); err != nil {
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.Filename, err)
@ -77,8 +83,17 @@ func Certification() http.HandlerFunc {
cert.Filename = ""
}
// Secondary cert photo
if cert.SecondaryFilename != "" {
if err := photo.Delete(cert.SecondaryFilename); err != nil {
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
}
cert.SecondaryFilename = ""
}
cert.Status = models.CertificationPhotoNeeded
cert.AdminComment = ""
cert.SecondaryVerified = false
cert.Save()
// Removing your photo = not certified again.
@ -87,11 +102,22 @@ func Certification() http.HandlerFunc {
session.FlashError(w, r, "Error saving your User data: %s", err)
}
// Log the change.
models.LogDeleted(currentUser, nil, "certification_photos", currentUser.ID, "Removed their certification photo.", cert)
// Kick them from the chat room if they are online.
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
}
session.Flash(w, r, "Your certification photo has been deleted.")
templates.Redirect(w, r.URL.Path)
return
}
// Is it their secondary form of ID being uploaded?
isSecondary := r.PostFormValue("secondary") == "true"
// Get the uploaded file.
file, header, err := r.FormFile("file")
if err != nil {
@ -115,17 +141,38 @@ func Certification() http.HandlerFunc {
}
// Are they replacing their old photo?
if cert.Filename != "" {
if cert.Filename != "" && !isSecondary {
if err := photo.Delete(cert.Filename); err != nil {
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.Filename, err)
}
} else if isSecondary && cert.SecondaryFilename != "" {
if err := photo.Delete(cert.SecondaryFilename); err != nil {
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
}
}
// Update their certification photo.
cert.Status = models.CertificationPhotoPending
cert.Filename = filename
if isSecondary {
cert.SecondaryFilename = filename
cert.SecondaryNeeded = true
cert.SecondaryVerified = false
} else {
cert.Filename = filename
}
cert.AdminComment = ""
cert.IPAddress = utility.IPAddress(r)
// Secondary ID workflow: if the user
// 1. Uploads a regular cert photo
// 2. An admin marks secondary ID as needed
// 3. They remove everything and reupload a new cert photo, without a secondary ID attached
// Then we don't e-mail the admin for approval yet, and move straight to Secondary ID Requested
// for the user to upload their secondary ID now.
if cert.Status == models.CertificationPhotoPending && cert.SecondaryNeeded && cert.SecondaryFilename == "" {
cert.Status = models.CertificationPhotoSecondary
}
if err := cert.Save(); err != nil {
session.FlashError(w, r, "Error saving your CertificationPhoto: %s", err)
templates.Redirect(w, r.URL.Path)
@ -138,19 +185,31 @@ func Certification() http.HandlerFunc {
session.FlashError(w, r, "Error saving your User data: %s", err)
}
// Notify the admin email to check out this photo.
if err := mail.Send(mail.Message{
To: config.Current.AdminEmail,
Subject: "New Certification Photo Needs Approval",
Template: "email/certification_admin.html",
Data: map[string]interface{}{
"User": currentUser,
"URL": config.Current.BaseURL + "/admin/photo/certification",
},
}); err != nil {
log.Error("Certification: failed to notify admins of pending photo: %s", err)
// Kick them from the chat room if they are online.
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
}
// Log the change. Note the original IP and GeoIP insights - we once saw a spammer upload
// their cert photo from Nigeria, and before we could reject it, they removed and reuploaded
// it from New York using a VPN. If it wasn't seen in real time, this might have slipped by.
var insights string
if i, err := geoip.GetRequestInsights(r); err == nil {
insights = i.String()
} else {
insights = "error: " + err.Error()
}
models.LogCreated(
currentUser,
"certification_photos",
currentUser.ID,
fmt.Sprintf(
"Uploaded a new certification photo.\n\n* From IP address: %s\n* GeoIP insight: %s",
cert.IPAddress,
insights,
),
)
session.Flash(w, r, "Your certification photo has been uploaded and is now awaiting approval.")
templates.Redirect(w, r.URL.Path)
return
@ -284,9 +343,20 @@ func AdminCertification() http.HandlerFunc {
} else {
cert.Status = models.CertificationPhotoRejected
cert.AdminComment = comment
if comment == "(ignore)" {
if comment == "(ignore)" || comment == "(secondary)" {
cert.AdminComment = ""
}
// With a secondary photo ID? Remove the photo ID immediately.
if cert.SecondaryFilename != "" {
// Delete it immediately.
if err := photo.Delete(cert.SecondaryFilename); err != nil {
session.FlashError(w, r, "Failed to delete old secondary ID cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
}
cert.SecondaryFilename = ""
cert.SecondaryVerified = false
}
if err := cert.Save(); err != nil {
session.FlashError(w, r, "Failed to save CertificationPhoto: %s", err)
templates.Redirect(w, r.URL.Path)
@ -297,6 +367,14 @@ func AdminCertification() http.HandlerFunc {
user.Certified = false
user.Save()
// Log the change.
models.LogEvent(user, currentUser, models.ChangeLogRejected, "certification_photos", user.ID, "Rejected the certification photo with comment: "+comment)
// Kick them from the chat room if they are online.
if _, err := chat.MaybeDisconnectUser(user); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
}
// Did we silently ignore it?
if comment == "(ignore)" {
session.FlashError(w, r, "The certification photo was ignored with no comment, and will not notify the sender.")
@ -304,6 +382,46 @@ func AdminCertification() http.HandlerFunc {
return
}
// Secondary verification required: the user will be asked to upload a blacked-out
// photo ID to be certified again.
if comment == "(secondary)" {
cert.Status = models.CertificationPhotoSecondary
cert.SecondaryNeeded = true
cert.SecondaryVerified = false
if err := cert.Save(); err != nil {
log.Error("Error saving cert photo: %s", err)
}
// Notify the user about this rejection.
notif := &models.Notification{
UserID: user.ID,
AboutUser: *user,
Type: models.NotificationCertSecondary,
Message: "A secondary form of photo ID is requested. Please [click here](/photo/certification) to learn more.",
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create rejection notification: %s", err)
}
// Notify the user via email.
if err := mail.Send(mail.Message{
To: user.Email,
Subject: "Regarding your nonshy certification photo",
Template: "email/certification_secondary.html",
Data: map[string]interface{}{
"Username": user.Username,
"AdminComment": comment,
"URL": config.Current.BaseURL + "/photo/certification",
},
}); err != nil {
session.FlashError(w, r, "Note: failed to email user about the rejection: %s", err)
}
session.Flash(w, r, "The user will be asked to provide a secondary form of ID.")
templates.Redirect(w, r.URL.Path)
return
}
// Notify the user about this rejection.
notif := &models.Notification{
UserID: user.ID,
@ -316,17 +434,21 @@ func AdminCertification() http.HandlerFunc {
}
// Notify the user via email.
if err := mail.Send(mail.Message{
To: user.Email,
Subject: "Your certification photo has been rejected",
Template: "email/certification_rejected.html",
Data: map[string]interface{}{
"Username": user.Username,
"AdminComment": comment,
"URL": config.Current.BaseURL + "/photo/certification",
},
}); err != nil {
session.FlashError(w, r, "Note: failed to email user about the rejection: %s", err)
if err := mail.LockSending("cert_rejected", user.Email, config.EmailDebounceDefault); err == nil {
if err := mail.Send(mail.Message{
To: user.Email,
Subject: "Your certification photo has been denied",
Template: "email/certification_rejected.html",
Data: map[string]interface{}{
"Username": user.Username,
"AdminComment": comment,
"URL": config.Current.BaseURL + "/photo/certification",
},
}); err != nil {
session.FlashError(w, r, "Note: failed to email user about the rejection: %s", err)
}
} else {
log.Error("LockSending: cert_rejected e-mail is not sent to %s: one was sent recently", user.Email)
}
}
@ -334,6 +456,36 @@ func AdminCertification() http.HandlerFunc {
case "approve":
cert.Status = models.CertificationPhotoApproved
cert.AdminComment = ""
// With a secondary photo ID?
if cert.SecondaryFilename != "" {
// Move the original photo into cold storage.
coldStorageFilename := fmt.Sprintf(
"photoID-%d-%s-%d.jpg",
user.ID,
user.Username,
time.Now().Unix(),
)
if err := coldstorage.FileToColdStorage(
photo.DiskPath(cert.SecondaryFilename),
coldStorageFilename,
config.Current.Encryption.ColdStorageRSAPublicKey,
); err != nil {
session.FlashError(w, r, "Failed to move to cold storage: %s", err)
templates.Redirect(w, r.URL.Path)
return
} else {
session.Flash(w, r, "Note: the secondary photo ID was encrypted to cold storage @ %s", coldStorageFilename)
}
// Delete it immediately.
if err := photo.Delete(cert.SecondaryFilename); err != nil {
session.FlashError(w, r, "Failed to delete old secondary ID cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
}
cert.SecondaryFilename = ""
cert.SecondaryVerified = true
}
if err := cert.Save(); err != nil {
session.FlashError(w, r, "Failed to save CertificationPhoto: %s", err)
templates.Redirect(w, r.URL.Path)
@ -355,18 +507,25 @@ func AdminCertification() http.HandlerFunc {
}
// Notify the user via email.
if err := mail.Send(mail.Message{
To: user.Email,
Subject: "Your certification photo has been approved!",
Template: "email/certification_approved.html",
Data: map[string]interface{}{
"Username": user.Username,
"URL": config.Current.BaseURL,
},
}); err != nil {
session.FlashError(w, r, "Note: failed to email user about the approval: %s", err)
if err := mail.LockSending("cert_approved", user.Email, config.EmailDebounceDefault); err == nil {
if err := mail.Send(mail.Message{
To: user.Email,
Subject: "Your certification photo has been approved!",
Template: "email/certification_approved.html",
Data: map[string]interface{}{
"Username": user.Username,
"URL": config.Current.BaseURL,
},
}); err != nil {
session.FlashError(w, r, "Note: failed to email user about the approval: %s", err)
}
} else {
log.Error("LockSending: cert_approved e-mail is not sent to %s: one was sent recently", user.Email)
}
// Log the change.
models.LogEvent(user, currentUser, models.ChangeLogApproved, "certification_photos", user.ID, "Approved the certification photo.")
session.Flash(w, r, "Certification photo approved!")
default:
session.FlashError(w, r, "Unsupported verdict option: %s", verdict)

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,9 +44,13 @@ func Edit() http.HandlerFunc {
return
}
// In case an admin is editing this photo: remember the HTTP request current user,
// before the currentUser may be set to the photo's owner below.
var requestUser = currentUser
// Do we have permission for this photo?
if photo.UserID != currentUser.ID {
if !currentUser.IsAdmin {
if !currentUser.HasAdminScope(config.ScopePhotoModerator) {
templates.ForbiddenPage(w, r)
return
}
@ -65,32 +71,69 @@ func Edit() http.HandlerFunc {
// Are we saving the changes?
if r.Method == http.MethodPost {
// Record if this change is going to make them a Shy Account.
var wasShy = currentUser.IsShy()
var (
caption = r.FormValue("caption")
caption = strings.TrimSpace(r.FormValue("caption"))
altText = strings.TrimSpace(r.FormValue("alt_text"))
isExplicit = r.FormValue("explicit") == "true"
isGallery = r.FormValue("gallery") == "true"
isPinned = r.FormValue("pinned") == "true"
visibility = models.PhotoVisibility(r.FormValue("visibility"))
// Profile pic fields
setProfilePic = r.FormValue("intent") == "profile-pic"
crop = pphoto.ParseCropCoords(r.FormValue("crop"))
// Re-compute the face score (admin only)
recomputeFaceScore = r.FormValue("recompute_face_score") == "true" && currentUser.IsAdmin
// Are we GOING private or changing to Inner Circle?
// Are we GOING private?
goingPrivate = visibility == models.PhotoPrivate && visibility != photo.Visibility
goingCircle = visibility == models.PhotoInnerCircle && visibility != photo.Visibility
// Is the user fighting an 'Explicit' tag added by the community?
isFightingExplicitFlag = photo.Flagged && photo.Explicit && !isExplicit
)
if len(altText) > config.AltTextMaxLength {
altText = altText[:config.AltTextMaxLength]
}
// Respect the Site Gallery throttle in case the user is messing around.
if SiteGalleryThrottled {
isGallery = false
}
// Diff for the ChangeLog.
diffs := []models.FieldDiff{
models.NewFieldDiff("Caption", photo.Caption, caption),
models.NewFieldDiff("Explicit", photo.Explicit, isExplicit),
models.NewFieldDiff("Gallery", photo.Gallery, isGallery),
models.NewFieldDiff("Pinned", photo.Pinned, isPinned),
models.NewFieldDiff("Visibility", photo.Visibility, visibility),
}
// Admin label options.
if requestUser.HasAdminScope(config.ScopePhotoModerator) {
var adminLabel string
if labels, ok := r.PostForm["admin_label"]; ok {
adminLabel = strings.Join(labels, ",")
}
diffs = append(diffs,
models.NewFieldDiff("Admin Label", photo.AdminLabel, adminLabel),
)
photo.AdminLabel = adminLabel
}
// Admin label: forced explicit?
if photo.HasAdminLabelForceExplicit() {
isExplicit = true
}
photo.Caption = caption
photo.AltText = altText
photo.Explicit = isExplicit
photo.Gallery = isGallery
photo.Pinned = isPinned
photo.Visibility = visibility
// Can not use a GIF as profile pic.
@ -119,17 +162,32 @@ func Edit() http.HandlerFunc {
setProfilePic = false
}
log.Error("SAVING PHOTO: %+v", photo)
// If the user is fighting a recent Explicit flag from the community.
if isFightingExplicitFlag {
// Are we re-computing the face score?
if recomputeFaceScore {
score, err := pphoto.ComputeFaceScore(pphoto.DiskPath(photo.Filename))
if err != nil {
session.FlashError(w, r, "Face score: %s", err)
} else {
session.Flash(w, r, "Face score recomputed!")
photo.FaceScore = &score
// Notify the admin (unless we are an admin).
if !requestUser.IsAdmin {
fb := &models.Feedback{
Intent: "report",
Subject: "Explicit photo flag dispute",
UserID: currentUser.ID,
TableName: "photos",
TableID: photo.ID,
Message: "A user's photo was recently **flagged by the community** as Explicit, and its owner " +
"has **removed** the Explicit setting.\n\n" +
"Please check out the photo below and verify what its Explicit setting should be:",
}
if err := models.CreateFeedback(fb); err != nil {
log.Error("Couldn't save feedback from user updating their DOB: %s", err)
}
}
// Allow this change but clear the Flagged status.
photo.Flagged = false
// Clear the notification about this.
models.RemoveSpecificNotification(currentUser.ID, models.NotificationExplicitPhoto, "photos", photo.ID)
}
if err := photo.Save(); err != nil {
@ -148,14 +206,25 @@ func Edit() http.HandlerFunc {
// Flash success.
session.Flash(w, r, "Photo settings updated!")
// Log the change.
models.LogUpdated(currentUser, requestUser, "photos", photo.ID, "Updated the photo's settings.", diffs)
// Maybe kick them from the chat if this photo save makes them a Shy Account.
currentUser.FlushCaches()
if !wasShy && currentUser.IsShy() {
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
}
}
// If this picture has moved to Private, revoke any notification we gave about it before.
if goingPrivate || goingCircle {
if goingPrivate {
log.Info("The picture is GOING PRIVATE (to %s), revoke any notifications about it", photo.Visibility)
models.RemoveNotification("photos", photo.ID)
}
// Return the user to their gallery.
templates.Redirect(w, "/photo/u/"+currentUser.Username)
templates.Redirect(w, "/u/"+currentUser.Username+"/photos")
return
}
@ -163,6 +232,10 @@ func Edit() http.HandlerFunc {
"EditPhoto": photo,
"SiteGalleryThrottled": SiteGalleryThrottled,
"SiteGalleryThrottleLimit": config.SiteGalleryRateLimitMax,
// Available admin labels enum.
"RequestUser": requestUser,
"AvailableAdminLabels": config.AdminLabelPhotoOptions,
}
if err := tmpl.Execute(w, r, vars); err != nil {
@ -173,109 +246,10 @@ func Edit() http.HandlerFunc {
}
// Delete controller (/photo/Delete?id=N) to change properties about your picture.
//
// DEPRECATED: send them to the batch-edit endpoint.
func Delete() http.HandlerFunc {
// Reuse the upload page but with an EditPhoto variable.
tmpl := templates.Must("photo/delete.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query params.
photoID, err := strconv.Atoi(r.FormValue("id"))
if err != nil {
log.Error("photo.Delete: failed to parse `id` param (%s) as int: %s", r.FormValue("id"), err)
session.FlashError(w, r, "Photo 'id' parameter required.")
templates.Redirect(w, "/")
return
}
// Page to redirect to in case of errors.
redirect := fmt.Sprintf("%s?id=%d", r.URL.Path, photoID)
// Find this photo by ID.
photo, err := models.GetPhoto(uint64(photoID))
if err != nil {
templates.NotFoundPage(w, r)
return
}
// Load the current user.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
templates.Redirect(w, "/")
return
}
// Do we have permission for this photo?
if photo.UserID != currentUser.ID {
if !currentUser.IsAdmin {
templates.ForbiddenPage(w, r)
return
}
// Find the owner of this photo and assume currentUser is them for the remainder
// of this controller.
if user, err := models.GetUser(photo.UserID); err != nil {
session.FlashError(w, r, "Couldn't get the owner User for this photo!")
templates.Redirect(w, "/")
return
} else {
currentUser = user
}
}
// Confirm deletion?
if r.Method == http.MethodPost {
confirm := r.PostFormValue("confirm") == "true"
if !confirm {
session.FlashError(w, r, "Confirm you want to delete this photo.")
templates.Redirect(w, redirect)
return
}
// Was this our profile picture?
if currentUser.ProfilePhotoID != nil && *currentUser.ProfilePhotoID == photo.ID {
log.Debug("Delete Photo: was the user's profile photo, unset ProfilePhotoID")
if err := currentUser.RemoveProfilePhoto(); err != nil {
session.FlashError(w, r, "Error unsetting your current profile photo: %s", err)
templates.Redirect(w, redirect)
return
}
}
// Remove the images from disk.
for _, filename := range []string{
photo.Filename,
photo.CroppedFilename,
} {
if len(filename) > 0 {
if err := pphoto.Delete(filename); err != nil {
log.Error("Delete Photo: couldn't remove file from disk: %s: %s", filename, err)
}
}
}
// Take back notifications on it.
models.RemoveNotification("photos", photo.ID)
if err := photo.Delete(); err != nil {
session.FlashError(w, r, "Couldn't delete photo: %s", err)
templates.Redirect(w, redirect)
return
}
session.Flash(w, r, "Photo deleted!")
// Return the user to their gallery.
templates.Redirect(w, "/photo/u/"+currentUser.Username)
return
}
var vars = map[string]interface{}{
"Photo": photo,
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
templates.Redirect(w, fmt.Sprintf("/photo/batch-edit?intent=delete&id=%s", r.FormValue("id")))
})
}

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,7 +42,11 @@ func Private() http.HandlerFunc {
return
}
log.Error("pager: %+v, len: %d", pager, len(users))
// Collect user IDs for some mappings.
var userIDs = []uint64{}
for _, user := range users {
userIDs = append(userIDs, user.ID)
}
// Map reverse grantee statuses.
var GranteeMap interface{}
@ -60,6 +64,12 @@ func Private() http.HandlerFunc {
"GranteeMap": GranteeMap,
"Users": users,
"Pager": pager,
// Mapped user statuses for frontend cards.
"PhotoCountMap": models.MapPhotoCountsByVisibility(users, models.PhotoPrivate),
"FriendMap": models.MapFriends(currentUser, users),
"LikedMap": models.MapLikes(currentUser, "users", userIDs),
"ShyMap": models.MapShyAccounts(users),
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@ -131,6 +141,15 @@ func Share() http.HandlerFunc {
intent = r.PostFormValue("intent")
)
// Is the recipient blocking this photo share?
if intent != "decline" && intent != "revoke" {
if ok, err := models.ShouldShowPrivateUnlockPrompt(currentUser, user); !ok {
session.FlashError(w, r, "You are unable to share your private photos with %s: %s.", user.Username, err)
templates.Redirect(w, "/u/"+user.Username)
return
}
}
// If submitting, do it and redirect.
if intent == "submit" {
models.UnlockPrivatePhotos(currentUser.ID, user.ID)
@ -145,7 +164,7 @@ func Share() http.HandlerFunc {
Type: models.NotificationPrivatePhoto,
TableName: "__private_photos",
TableID: currentUser.ID,
Link: fmt.Sprintf("/photo/u/%s?visibility=private", currentUser.Username),
Link: fmt.Sprintf("/u/%s/photos?visibility=private", currentUser.Username),
}
if err := models.CreateNotification(notif); err != nil {
log.Error("Couldn't create PrivatePhoto notification: %s", err)
@ -162,10 +181,25 @@ func Share() http.HandlerFunc {
models.RemoveSpecificNotification(user.ID, models.NotificationPrivatePhoto, "__private_photos", currentUser.ID)
// Revoke any "has uploaded a new private photo" notifications in this user's list.
if err := models.RevokePrivatePhotoNotifications(currentUser, &user.ID); err != nil {
if err := models.RevokePrivatePhotoNotifications(currentUser, user); err != nil {
log.Error("RevokePrivatePhotoNotifications(%s): %s", currentUser.Username, err)
}
return
} else if intent == "decline" {
// Decline = they shared with me and we do not want it.
models.RevokePrivatePhotos(user.ID, currentUser.ID)
session.Flash(w, r, "You have declined access to see %s's private photos.", user.Username)
// Remove any notification we created when the grant was given.
models.RemoveSpecificNotification(currentUser.ID, models.NotificationPrivatePhoto, "__private_photos", user.ID)
// Revoke any "has uploaded a new private photo" notifications in this user's list.
if err := models.RevokePrivatePhotoNotifications(user, currentUser); err != nil {
log.Error("RevokePrivatePhotoNotifications(%s): %s", user.Username, err)
}
templates.Redirect(w, "/photo/private?view=grantee")
return
}
// The other intent is "preview" so the user gets the confirmation

View File

@ -4,6 +4,7 @@ import (
"net/http"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates"
@ -17,6 +18,9 @@ func SiteGallery() http.HandlerFunc {
var sortWhitelist = []string{
"created_at desc",
"created_at asc",
"like_count desc",
"comment_count desc",
"views desc",
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -64,13 +68,18 @@ func SiteGallery() http.HandlerFunc {
// They didn't post a "Whose photos" filter, restore it from their last saved default.
who = currentUser.GetProfileField("site_gallery_default")
}
if who != "friends" && who != "everybody" && who != "friends+private" {
if who != "friends" && who != "everybody" && who != "friends+private" && who != "likes" && who != "uncertified" {
// Default Who setting should be Friends-only, unless you have no friends.
if myFriendCount > 0 {
who = "friends"
} else {
who = "everybody"
}
// Admin only who option.
if who == "uncertified" && !currentUser.HasAdminScope(config.ScopePhotoModerator) {
who = "friends"
}
}
// Store their "Whose photos" filter on their page to default it for next time.
@ -94,6 +103,8 @@ func SiteGallery() http.HandlerFunc {
AdminView: adminView,
FriendsOnly: who == "friends",
IsShy: isShy || who == "friends+private",
MyLikes: who == "likes",
Uncertified: who == "uncertified",
}, pager)
// Bulk load the users associated with these photos.
@ -114,6 +125,13 @@ func SiteGallery() http.HandlerFunc {
likeMap := models.MapLikes(currentUser, "photos", photoIDs)
commentMap := models.MapCommentCounts("photos", photoIDs)
// Ping this user as having used the forums today.
go func() {
if err := models.LogDailyGalleryUser(currentUser); err != nil {
log.Error("LogDailyGalleryUser(%s): error logging their usage statistic: %s", currentUser.Username, err)
}
}()
var vars = map[string]interface{}{
"IsSiteGallery": true,
"Photos": photos,

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,10 +61,12 @@ func Upload() http.HandlerFunc {
// Are they POSTing?
if r.Method == http.MethodPost {
var (
caption = r.PostFormValue("caption")
caption = strings.TrimSpace(r.PostFormValue("caption"))
altText = strings.TrimSpace(r.PostFormValue("alt_text"))
isExplicit = r.PostFormValue("explicit") == "true"
visibility = r.PostFormValue("visibility")
isGallery = r.PostFormValue("gallery") == "true"
isPinned = r.PostFormValue("pinned") == "true"
cropCoords = r.PostFormValue("crop")
confirm1 = r.PostFormValue("confirm1") == "true"
confirm2 = r.PostFormValue("confirm2") == "true"
@ -73,10 +77,14 @@ func Upload() http.HandlerFunc {
isGallery = false
}
if len(altText) > config.AltTextMaxLength {
altText = altText[:config.AltTextMaxLength]
}
// Are they at quota already?
if photoCount >= photoQuota {
session.FlashError(w, r, "You have too many photos to upload a new one. Please delete a photo to make room for a new one.")
templates.Redirect(w, "/photo/u/"+user.Username)
templates.Redirect(w, "/u/"+user.Username+"/photos")
return
}
@ -134,8 +142,10 @@ func Upload() http.HandlerFunc {
Filename: filename,
CroppedFilename: cropFilename,
Caption: caption,
AltText: altText,
Visibility: models.PhotoVisibility(visibility),
Gallery: isGallery,
Pinned: isPinned,
Explicit: isExplicit,
}
@ -159,11 +169,24 @@ func Upload() http.HandlerFunc {
user.Save()
}
// ChangeLog entry.
models.LogCreated(user, "photos", p.ID, fmt.Sprintf(
"Uploaded a new photo.\n\n"+
"* Caption: %s\n"+
"* Visibility: %s\n"+
"* Gallery: %v\n"+
"* Explicit: %v",
p.Caption,
p.Visibility,
p.Gallery,
p.Explicit,
))
// Notify all of our friends that we posted a new picture.
go notifyFriendsNewPhoto(p, user)
session.Flash(w, r, "Your photo has been uploaded successfully.")
templates.Redirect(w, "/photo/u/"+user.Username)
templates.Redirect(w, "/u/"+user.Username+"/photos")
return
}
@ -199,15 +222,6 @@ func notifyFriendsNewPhoto(photo *models.Photo, currentUser *models.User) {
notifyUserIDs = models.PrivateGranteeUserIDs(currentUser.ID)
log.Info("Notify %d private grantees about the new photo by %s", len(notifyUserIDs), currentUser.Username)
}
} else if photo.Visibility == models.PhotoInnerCircle {
// Inner circle members. If the pic is also Explicit, further narrow to explicit friend IDs.
if photo.Explicit {
notifyUserIDs = models.FriendIDsInCircleAreExplicit(currentUser.ID)
log.Info("Notify %d EXPLICIT circle friends about the new photo by %s", len(notifyUserIDs), currentUser.Username)
} else {
notifyUserIDs = models.FriendIDsInCircle(currentUser.ID)
log.Info("Notify %d circle friends about the new photo by %s", len(notifyUserIDs), currentUser.Username)
}
} else {
// Friends only: we will notify exactly the friends we selected above.
notifyUserIDs = friendIDs

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,21 +10,24 @@ import (
"code.nonshy.com/nonshy/website/pkg/templates"
)
var UserPhotosRegexp = regexp.MustCompile(`^/photo/u/([^@]+?)$`)
// UserPhotos controller (/photo/u/:username) to view a user's gallery or manage if it's yourself.
func UserPhotos() http.HandlerFunc {
tmpl := templates.Must("photo/gallery.html")
// Whitelist for ordering options.
var sortWhitelist = []string{
"pinned desc nulls last, updated_at desc",
"created_at desc",
"created_at asc",
"like_count desc",
"comment_count desc",
"views desc",
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query params.
var (
username = r.PathValue("username")
viewStyle = r.FormValue("view") // cards (default), full
// Search filters.
@ -33,9 +35,6 @@ func UserPhotos() http.HandlerFunc {
filterVisibility = r.FormValue("visibility")
sort = r.FormValue("sort")
sortOK bool
// Inner circle invite view?
innerCircleInvite = r.FormValue("intent") == "inner_circle"
)
// Sort options.
@ -54,13 +53,6 @@ func UserPhotos() http.HandlerFunc {
viewStyle = "cards"
}
// Parse the username out of the URL parameters.
var username string
m := UserPhotosRegexp.FindStringSubmatch(r.URL.Path)
if m != nil {
username = m[1]
}
// Find this user.
user, err := models.FindUser(username)
if err != nil {
@ -81,11 +73,6 @@ func UserPhotos() http.HandlerFunc {
isShyFrom = !isOwnPhotos && (currentUser.IsShyFrom(user) || (isShy && !areFriends))
)
// Inner circle invite: not if we are not in the circle ourselves.
if innerCircleInvite && !currentUser.IsInnerCircle() {
innerCircleInvite = false
}
// Bail early if we are shy from this user.
if isShy && isShyFrom {
var vars = map[string]interface{}{
@ -134,11 +121,6 @@ func UserPhotos() http.HandlerFunc {
visibility = append(visibility, models.PhotoFriends)
}
// Inner circle photos.
if currentUser.IsInnerCircle() {
visibility = append(visibility, models.PhotoInnerCircle)
}
// If we are Filtering by Visibility, ensure the target visibility is accessible to us.
if filterVisibility != "" {
var isOK bool
@ -203,13 +185,28 @@ func UserPhotos() http.HandlerFunc {
profilePictureHidden = visibility
}
// Friend Photos Notification Opt-out:
// If your friend posts too many photos and you want to mute them.
// NOTE: notifications are "on by default" and only an explicit "false"
// stored in the database indicates an opt-out.
// New photo upload notification subscription status.
var areNotificationsMuted bool
if exists, v := models.IsSubscribed(currentUser, "friend.photos", user.ID); exists {
areNotificationsMuted = !v
}
// Should the current user be able to share their private photos with the target?
showPrivateUnlockPrompt, _ := models.ShouldShowPrivateUnlockPrompt(currentUser, user)
var vars = map[string]interface{}{
"IsOwnPhotos": currentUser.ID == user.ID,
"IsShyUser": isShy,
"IsShyFrom": isShyFrom,
"IsMyPrivateUnlockedFor": isGranted, // have WE granted THIS USER to see our private pics?
"AreWeGrantedPrivate": isGrantee, // have THEY granted US private photo access.
"ShowPrivateUnlockPrompt": showPrivateUnlockPrompt,
"AreFriends": areFriends,
"AreNotificationsMuted": areNotificationsMuted,
"ProfilePictureHiddenVisibility": profilePictureHidden,
"User": user,
"Photos": photos,
@ -217,13 +214,11 @@ func UserPhotos() http.HandlerFunc {
"NoteCount": models.CountNotesAboutUser(currentUser, user),
"FriendCount": models.CountFriends(user.ID),
"PublicPhotoCount": models.CountPublicPhotos(user.ID),
"InnerCircleMinimumPublicPhotos": config.InnerCircleMinimumPublicPhotos,
"Pager": pager,
"LikeMap": likeMap,
"CommentMap": commentMap,
"ViewStyle": viewStyle,
"ExplicitCount": explicitCount,
"InnerCircleInviteView": innerCircleInvite,
// Search filters
"Sort": sort,

View File

@ -35,6 +35,12 @@ func View() http.HandlerFunc {
}
}
// Load the current user in case they are viewing their own page.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
}
// Find the photo's owner.
user, err := models.GetUser(photo.UserID)
if err != nil {
@ -42,40 +48,10 @@ func View() http.HandlerFunc {
return
}
// Load the current user in case they are viewing their own page.
currentUser, err := session.CurrentUser(r)
if err != nil {
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
}
var isOwnPhoto = currentUser.ID == user.ID
// Is either one blocking?
if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
return
}
// Is this a circle photo?
if photo.Visibility == models.PhotoInnerCircle && !currentUser.IsInnerCircle() {
templates.NotFoundPage(w, r)
return
}
// Is this user private and we're not friends?
var (
areFriends = models.AreFriends(user.ID, currentUser.ID)
isPrivate = user.Visibility == models.UserVisibilityPrivate && !areFriends
)
if isPrivate && !currentUser.IsAdmin && !isOwnPhoto {
session.FlashError(w, r, "This user's profile page and photo gallery are private.")
templates.Redirect(w, "/u/"+user.Username)
return
}
// Is this a private photo and are we allowed to see?
isGranted := models.IsPrivateUnlocked(user.ID, currentUser.ID)
if photo.Visibility == models.PhotoPrivate && !isGranted && !isOwnPhoto && !currentUser.IsAdmin {
templates.NotFoundPage(w, r)
if ok, err := photo.CanBeSeenBy(currentUser); !ok {
log.Error("Photo %d can't be seen by %s: %s", photo.ID, currentUser.Username, err)
session.FlashError(w, r, "Photo Not Found")
templates.Redirect(w, "/")
return
}
@ -108,6 +84,11 @@ func View() http.HandlerFunc {
// Is the current user subscribed to notifications on this thread?
_, isSubscribed := models.IsSubscribed(currentUser, "photos", photo.ID)
// Mark this photo as "Viewed" by the user.
if err := photo.View(currentUser); err != nil {
log.Error("Update photo(%d) views: %s", photo.ID, err)
}
var vars = map[string]interface{}{
"IsOwnPhoto": currentUser.ID == user.ID,
"User": user,

View File

@ -0,0 +1,150 @@
package coldstorage
import (
"crypto/rand"
"crypto/rsa"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"code.nonshy.com/nonshy/website/pkg/encryption/keygen"
"code.nonshy.com/nonshy/website/pkg/log"
)
var (
ColdStorageDirectory = "./coldstorage"
ColdStorageKeysDirectory = path.Join(ColdStorageDirectory, "keys")
ColdStoragePrivateKeyFile = path.Join(ColdStorageKeysDirectory, "private.pem")
ColdStoragePublicKeyFile = path.Join(ColdStorageKeysDirectory, "public.pem")
)
// Initialize generates the RSA key pairs for the first time and creates
// the cold storage directories. It writes the keys to disk and returns the x509 encoded
// public key which goes in the settings.json (the keys on disk are for your bookkeeping).
func Initialize() ([]byte, error) {
log.Warn("NOTICE: rolling a random RSA key pair for cold storage")
rsaKey, err := keygen.NewRSAKeys()
if err != nil {
return nil, fmt.Errorf("generate RSA key: %s", err)
}
// Encode to x509
x509, err := keygen.SerializePublicKey(rsaKey.Public())
if err != nil {
return nil, fmt.Errorf("encode RSA public key to x509: %s", err)
}
// Write the public/private key files to disk.
if _, err := os.Stat(ColdStorageKeysDirectory); os.IsNotExist(err) {
log.Info("Notice: creating cold storage directory")
if err := os.MkdirAll(ColdStorageKeysDirectory, 0755); err != nil {
return nil, fmt.Errorf("create %s: %s", ColdStorageKeysDirectory, err)
}
}
if err := keygen.WriteRSAKeys(
rsaKey,
path.Join(ColdStorageKeysDirectory, "private.pem"),
path.Join(ColdStorageKeysDirectory, "public.pem"),
); err != nil {
return nil, fmt.Errorf("export newly generated public/private key files: %s", err)
}
return x509, nil
}
// Warning returns an error message if the private key is still on disk at its
// original generated location: it should be moved offline for security.
func Warning() error {
if _, err := os.Stat(ColdStoragePrivateKeyFile); os.IsNotExist(err) {
return nil
}
return errors.New("the private key file at ./coldstorage/keys should be moved off of the server and kept offline for safety")
}
// FileToColdStorage will copy a file, encrypted, into cold storage at the given filename.
func FileToColdStorage(sourceFilePath, outputFileName string, publicKeyPEM []byte) error {
if len(publicKeyPEM) == 0 {
return errors.New("no RSA public key")
}
// Load the public key from PEM encoding.
publicKey, err := keygen.DeserializePublicKey(publicKeyPEM)
if err != nil {
return fmt.Errorf("deserializing public key: %s", err)
}
// Generate a unique AES key for encrypting this file in one direction.
aesKey, err := keygen.NewAESKey()
if err != nil {
return err
}
// Encrypt the AES key and store it on disk next to the cold storage file.
ciphertext, err := rsa.EncryptPKCS1v15(rand.Reader, publicKey, aesKey)
if err != nil {
return fmt.Errorf("encrypt error: %s", err)
}
err = os.WriteFile(
filepath.Join(ColdStorageDirectory, outputFileName+".aes"),
ciphertext,
0600,
)
if err != nil {
return err
}
// Read the original plaintext file going into cold storage.
plaintext, err := os.ReadFile(sourceFilePath)
if err != nil {
return fmt.Errorf("source file: %s", err)
}
// Encrypt the plaintext with the AES key.
ciphertext, err = keygen.EncryptWithAESKey(plaintext, aesKey)
if err != nil {
return err
}
// Write it to disk.
return os.WriteFile(filepath.Join(ColdStorageDirectory, outputFileName+".enc"), ciphertext, 0600)
}
// FileFromColdStorage decrypts a cold storage file and writes it to the output file.
//
// The command `nonshy coldstorage decrypt` uses this function. Requirements:
//
// - privateKeyFile is the RSA private key originally generated for cold storage
// - aesKeyFile is the unique .aes file for the cold storage item
// - ciphertextFile is the encrypted cold storage item
// - outputFile is where you want to save the result to
func FileFromColdStorage(privateKeyFile, aesKeyFile, ciphertextFile, outputFile string) error {
privateKey, err := keygen.PrivateKeyFromFile(privateKeyFile)
if err != nil {
return fmt.Errorf("private key file: %s", err)
}
encryptedAESKey, err := os.ReadFile(aesKeyFile)
if err != nil {
return fmt.Errorf("reading aes key file: %s", err)
}
aesKey, err := rsa.DecryptPKCS1v15(rand.Reader, privateKey, encryptedAESKey)
if err != nil {
return fmt.Errorf("decrypting the aes key file: %s", err)
}
ciphertext, err := os.ReadFile(ciphertextFile)
if err != nil {
return fmt.Errorf("reading cold storage file: %s", err)
}
plaintext, err := keygen.DecryptWithAESKey(ciphertext, aesKey)
if err != nil {
return fmt.Errorf("decrypting cold storage file: %s", err)
}
return os.WriteFile(outputFile, plaintext, 0644)
}

View File

@ -7,15 +7,12 @@
package encryption
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"io"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/encryption/keygen"
)
// Encrypt a byte stream using the site's AES passphrase.
@ -24,32 +21,7 @@ func Encrypt(input []byte) ([]byte, error) {
return nil, errors.New("AES key not configured")
}
// Generate a new AES cipher.
c, err := aes.NewCipher(config.Current.Encryption.AESKey)
if err != nil {
return nil, err
}
// gcm or Galois/Counter Mode
gcm, err := cipher.NewGCM(c)
if err != nil {
return nil, err
}
// Create a new byte array the size of the GCM nonce
// which must be passed to Seal.
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("populating the nonce: %s", err)
}
// Encrypt the text using the Seal function.
// Seal encrypts and authenticates plaintext, authenticates the
// additional data and appends the result to dst, returning the
// updated slice. The nonce must be NonceSize() bytes long and
// unique for all time, for a given key.
result := gcm.Seal(nonce, nonce, input, nil)
return result, nil
return keygen.EncryptWithAESKey(input, config.Current.Encryption.AESKey)
}
// EncryptString encrypts a string value and returns the cipher text.
@ -63,27 +35,7 @@ func Decrypt(data []byte) ([]byte, error) {
return nil, errors.New("AES key not configured")
}
c, err := aes.NewCipher(config.Current.Encryption.AESKey)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(c)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return nil, errors.New("ciphertext data less than nonceSize")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
return keygen.DecryptWithAESKey(data, config.Current.Encryption.AESKey)
}
// DecryptString decrypts a string value from ciphertext.

71
pkg/encryption/jwt.go Normal file
View File

@ -0,0 +1,71 @@
package encryption
import (
"errors"
"fmt"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"github.com/golang-jwt/jwt/v4"
)
// StandardClaims returns a standard JWT claim for a username.
//
// It will include values for Subject (username), Issuer (site title), ExpiresAt, IssuedAt, NotBefore.
//
// If the userID is >0, the ID field is included.
func StandardClaims(userID uint64, username string, expiresAt time.Time) jwt.RegisteredClaims {
claim := jwt.RegisteredClaims{
Subject: username,
Issuer: config.Title,
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
}
if userID > 0 {
claim.ID = fmt.Sprintf("%d", userID)
}
return claim
}
// SignClaims creates and returns a signed JWT token.
func SignClaims(claims jwt.Claims, secret []byte) (string, error) {
// Get our Chat JWT secret.
if len(secret) == 0 {
return "", errors.New("JWT secret key is not configured")
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
ss, err := token.SignedString(secret)
if err != nil {
return "", err
}
return ss, nil
}
// ValidateClaims checks a JWT token is signed by the site key and returns the claims.
func ValidateClaims(tokenStr string, secret []byte, v jwt.Claims) (jwt.Claims, bool, error) {
// Handle a JWT authentication token.
var (
claims jwt.Claims
authOK bool
)
if tokenStr != "" {
token, err := jwt.ParseWithClaims(tokenStr, v, func(token *jwt.Token) (interface{}, error) {
return secret, nil
})
if err != nil {
return nil, false, err
}
if !token.Valid {
return nil, false, errors.New("token was not valid")
}
claims = token.Claims
authOK = true
}
return claims, authOK, nil
}

View File

@ -0,0 +1,72 @@
// Package keygen provides the AES key initializer function.
package keygen
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
)
// NewAESKey returns a 32-byte (AES 256 bit) encryption key.
func NewAESKey() ([]byte, error) {
var result = make([]byte, 32)
_, err := rand.Read(result)
return result, err
}
// EncryptWithAESKey a byte stream using a given AES key.
func EncryptWithAESKey(input []byte, key []byte) ([]byte, error) {
// Generate a new AES cipher.
c, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// gcm or Galois/Counter Mode
gcm, err := cipher.NewGCM(c)
if err != nil {
return nil, err
}
// Create a new byte array the size of the GCM nonce
// which must be passed to Seal.
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("populating the nonce: %s", err)
}
// Encrypt the text using the Seal function.
// Seal encrypts and authenticates plaintext, authenticates the
// additional data and appends the result to dst, returning the
// updated slice. The nonce must be NonceSize() bytes long and
// unique for all time, for a given key.
result := gcm.Seal(nonce, nonce, input, nil)
return result, nil
}
func DecryptWithAESKey(data []byte, key []byte) ([]byte, error) {
c, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(c)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return nil, errors.New("ciphertext data less than nonceSize")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}

View File

@ -0,0 +1,72 @@
package keygen_test
import (
"testing"
"code.nonshy.com/nonshy/website/pkg/encryption/keygen"
)
func TestAES(t *testing.T) {
type testCase struct {
AESKey []byte // AES key, nil = generate a new one
Input []byte // input text to encrypt
Encrypted []byte // already encrypted text
Expect []byte // expected output on decrypt
}
var tests = []testCase{
{
Input: []byte("hello world"),
Expect: []byte("hello world"),
},
{
AESKey: []byte{170, 94, 243, 132, 85, 247, 149, 238, 245, 39, 140, 125, 226, 178, 134, 161, 17, 151, 139, 248, 16, 94, 165, 8, 102, 238, 214, 183, 86, 138, 219, 52},
Encrypted: []byte{146, 217, 250, 254, 70, 201, 27, 221, 92, 145, 77, 213, 211, 197, 63, 189, 220, 188, 78, 8, 217, 108, 136, 89, 156, 23, 179, 54, 209, 54, 244, 170, 182, 150, 242, 52, 112, 191, 216, 46},
Expect: []byte("goodbye mars"),
},
}
for i, test := range tests {
if len(test.AESKey) == 0 {
key, err := keygen.NewAESKey()
if err != nil {
t.Errorf("Test #%d: failed to generate new AES key: %s", i, err)
continue
}
test.AESKey = key
}
if len(test.Encrypted) == 0 {
enc, err := keygen.EncryptWithAESKey(test.Input, test.AESKey)
if err != nil {
t.Errorf("Test #%d: failed to encrypt input: %s", i, err)
continue
}
test.Encrypted = enc
}
// t.Errorf("Key: %+v\nEnc: %+v", test.AESKey, test.Encrypted)
dec, err := keygen.DecryptWithAESKey(test.Encrypted, test.AESKey)
if err != nil {
t.Errorf("Test #%d: failed to decrypt: %s", i, err)
continue
}
// compare the results
var ok = true
if len(dec) != len(test.Expect) {
ok = false
} else {
for j := range dec {
if test.Expect[j] != dec[j] {
ok = false
}
}
}
if !ok {
t.Errorf("Test #%d: got unexpected result from decrypt. Expected %s, got %s", i, test.Expect, dec)
continue
}
}
}

View File

@ -1,11 +0,0 @@
// Package keygen provides the AES key initializer function.
package keygen
import "crypto/rand"
// NewAESKey returns a 32-byte (AES 256 bit) encryption key.
func NewAESKey() ([]byte, error) {
var result = make([]byte, 32)
_, err := rand.Read(result)
return result, err
}

View File

@ -0,0 +1,99 @@
// Package keygen provides the AES key initializer function.
package keygen
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"os"
"code.nonshy.com/nonshy/website/pkg/log"
)
// NewRSAKeys will generate an RSA 2048-bit key pair.
func NewRSAKeys() (*rsa.PrivateKey, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
return privateKey, err
}
// SerializePublicKey converts an RSA public key into an x509 PEM encoded byte string.
func SerializePublicKey(publicKey crypto.PublicKey) ([]byte, error) {
// Encode the public key to PEM format.
x509EncodedPub, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
return nil, err
}
pemEncodedPub := pem.EncodeToMemory(&pem.Block{
Type: "RSA PUBLIC KEY",
Bytes: x509EncodedPub,
})
return pemEncodedPub, nil
}
// DeserializePublicKey loads the RSA public key from the PEM encoded byte array.
func DeserializePublicKey(pemEncodedPub []byte) (*rsa.PublicKey, error) {
// Decode the public key.
log.Error("decode public key: %s", pemEncodedPub)
blockPub, _ := pem.Decode(pemEncodedPub)
x509EncodedPub := blockPub.Bytes
genericPublicKey, err := x509.ParsePKIXPublicKey(x509EncodedPub)
if err != nil {
return nil, err
}
publicKey := genericPublicKey.(*rsa.PublicKey)
return publicKey, nil
}
// WriteRSAKeys writes the public and private RSA keys to .pem files on disk.
func WriteRSAKeys(key *rsa.PrivateKey, privateFile, publicFile string) error {
// Encode the private key to PEM format.
x509Encoded := x509.MarshalPKCS1PrivateKey(key)
pemEncoded := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509Encoded,
})
// Encode the public key to PEM format.
pemEncodedPub, err := SerializePublicKey(key.Public())
if err != nil {
return err
}
// Write the files.
if err := os.WriteFile(privateFile, pemEncoded, 0600); err != nil {
return err
}
if err := os.WriteFile(publicFile, pemEncodedPub, 0644); err != nil {
return err
}
return nil
}
// PrivateKeyFromFile loads the private key from disk.
func PrivateKeyFromFile(privateFile string) (*rsa.PrivateKey, error) {
// Read the private key file.
pemEncoded, err := os.ReadFile(privateFile)
if err != nil {
return nil, err
}
// Decode the private key.
block, _ := pem.Decode(pemEncoded)
x509Encoded := block.Bytes
privateKey, _ := x509.ParsePKCS1PrivateKey(x509Encoded)
return privateKey, nil
}
// PublicKeyFromFile loads the public key from disk.
func PublicKeyFromFile(publicFile string) (*rsa.PublicKey, error) {
pemEncodedPub, err := os.ReadFile(publicFile)
if err != nil {
return nil, err
}
// Decode the public key.
return DeserializePublicKey(pemEncodedPub)
}

View File

@ -7,9 +7,12 @@ import (
"fmt"
"html/template"
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/encryption"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/redis"
"github.com/microcosm-cc/bluemonday"
"gopkg.in/gomail.v2"
)
@ -23,6 +26,22 @@ type Message struct {
Data map[string]interface{}
}
// LockSending emails to the same address within 24 hours, e.g.: on the signup form to reduce chance for spam abuse.
//
// Call this before calling Send() if you want to throttle the sending. This function will put a key in Redis on
// the first call and return nil; on subsequent calls, if the key still remains, it will return an error.
func LockSending(namespace, email string, expires time.Duration) error {
var key = fmt.Sprintf("mail/lock-sending/%s/%s", namespace, encryption.Hash([]byte(email)))
// See if we have already locked it.
if redis.Exists(key) {
return errors.New("email was in the lock-sending queue")
}
redis.Set(key, email, expires)
return nil
}
// Send an email.
func Send(msg Message) error {
conf := config.Current.Mail

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

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

View File

@ -0,0 +1,25 @@
package backfill
import (
"code.nonshy.com/nonshy/website/pkg/models"
)
// BackfillPhotoCounts recomputes the cached Likes and Comment counts on photos.
func BackfillPhotoCounts() error {
res := models.DB.Exec(`
UPDATE photos
SET like_count = (
SELECT count(id)
FROM likes
WHERE table_name='photos'
AND table_id=photos.id
),
comment_count = (
SELECT count(id)
FROM comments
WHERE table_name='photos'
AND table_id=photos.id
);
`)
return res.Error
}

View File

@ -199,6 +199,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
reverse = []*Block{} // Users who block the target
userIDs = []uint64{user.ID}
usernames = map[uint64]string{}
admins = map[uint64]bool{}
)
// Get the complete blocklist and bucket them into forward and reverse.
@ -218,6 +219,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
type scanItem struct {
ID uint64
Username string
IsAdmin bool
}
var scan = []scanItem{}
if res := DB.Table(
@ -225,6 +227,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
).Select(
"id",
"username",
"is_admin",
).Where(
"id IN ?", userIDs,
).Scan(&scan); res.Error != nil {
@ -233,6 +236,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
for _, row := range scan {
usernames[row.ID] = row.Username
admins[row.ID] = row.IsAdmin
}
}
@ -245,6 +249,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
if username, ok := usernames[row.TargetUserID]; ok {
result.Blocks = append(result.Blocks, BlocklistInsightUser{
Username: username,
IsAdmin: admins[row.TargetUserID],
Date: row.CreatedAt,
})
}
@ -253,6 +258,7 @@ func GetBlocklistInsights(user *User) (*BlocklistInsight, error) {
if username, ok := usernames[row.SourceUserID]; ok {
result.BlockedBy = append(result.BlockedBy, BlocklistInsightUser{
Username: username,
IsAdmin: admins[row.SourceUserID],
Date: row.CreatedAt,
})
}
@ -268,6 +274,7 @@ type BlocklistInsight struct {
type BlocklistInsightUser struct {
Username string
IsAdmin bool
Date time.Time
}

View File

@ -1,6 +1,7 @@
package models
import (
"errors"
"time"
"gorm.io/gorm"
@ -8,15 +9,18 @@ import (
// CertificationPhoto table.
type CertificationPhoto struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"uniqueIndex"`
Filename string
Filesize int64
Status CertificationPhotoStatus
AdminComment string
IPAddress string // the IP they uploaded the photo from
CreatedAt time.Time
UpdatedAt time.Time
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"uniqueIndex"`
Filename string
Filesize int64
Status CertificationPhotoStatus
AdminComment string
SecondaryNeeded bool // a secondary form of ID has been requested
SecondaryFilename string // photo ID upload
SecondaryVerified bool // mark true when ID checked so original can be deleted
IPAddress string // the IP they uploaded the photo from
CreatedAt time.Time
UpdatedAt time.Time
}
type CertificationPhotoStatus string
@ -26,6 +30,10 @@ const (
CertificationPhotoPending CertificationPhotoStatus = "pending"
CertificationPhotoApproved CertificationPhotoStatus = "approved"
CertificationPhotoRejected CertificationPhotoStatus = "rejected"
// If a photo is pending approval but the admin wants to engage the
// secondary check (prompt user for a photo ID upload)
CertificationPhotoSecondary CertificationPhotoStatus = "secondary"
)
// GetCertificationPhoto retrieves the user's record from the DB or upserts their initial record.
@ -43,6 +51,28 @@ func GetCertificationPhoto(userID uint64) (*CertificationPhoto, error) {
return p, result.Error
}
// CertifiedSince retrieve's the last updated date of the user's certification photo, if approved.
//
// This incurs a DB query for their cert photo.
func (u *User) CertifiedSince() (time.Time, error) {
if !u.Certified {
return time.Time{}, errors.New("user is not certified")
}
cert, err := GetCertificationPhoto(u.ID)
if err != nil {
return time.Time{}, err
}
if cert.Status != CertificationPhotoApproved {
// The edge case can come up if a user was manually certified but didn't have an approved picture.
// Return their CreatedAt instead.
return u.CreatedAt, nil
}
return cert.UpdatedAt, nil
}
// CertificationPhotosNeedingApproval returns a pager of the pictures that require admin approval.
func CertificationPhotosNeedingApproval(status CertificationPhotoStatus, pager *Pagination) ([]*CertificationPhoto, error) {
var p = []*CertificationPhoto{}

254
pkg/models/change_log.go Normal file
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

@ -13,12 +13,12 @@ import (
// Comment table - in forum threads, on profiles or photos, etc.
type Comment struct {
ID uint64 `gorm:"primaryKey"`
TableName string `gorm:"index"`
TableID uint64 `gorm:"index"`
TableName string `gorm:"index:idx_comment_composite"`
TableID uint64 `gorm:"index:idx_comment_composite"`
UserID uint64 `gorm:"index"`
User User
User User `json:"-"`
Message string
CreatedAt time.Time
CreatedAt time.Time `gorm:"index"`
UpdatedAt time.Time
}
@ -29,6 +29,16 @@ var CommentableTables = map[string]interface{}{
"threads": nil,
}
// SubscribableTables are the set of table names that allow notification subscriptions.
var SubscribableTables = map[string]interface{}{
"photos": nil,
"threads": nil,
// Special case: new photo uploads from your friends. You can't comment on this,
// but you can (un)subscribe from it all the same.
"friend.photos": nil,
}
// Preload related tables for the forum (classmethod).
func (c *Comment) Preload() *gorm.DB {
return DB.Preload("User.ProfilePhoto")
@ -94,21 +104,27 @@ func CountCommentsReceived(user *User) int64 {
}
// PaginateComments provides a page of comments on something.
func PaginateComments(user *User, tableName string, tableID uint64, pager *Pagination) ([]*Comment, error) {
//
// Note: noBlockLists is to facilitate user-owned forums, where forum owners/moderators should override the block lists
// and retain full visibility into all user comments on their forum. Default/recommended is to leave it false, where
// the user's block list filters the view.
func PaginateComments(user *User, tableName string, tableID uint64, noBlockLists bool, pager *Pagination) ([]*Comment, error) {
var (
cs = []*Comment{}
query = (&Comment{}).Preload()
blockedUserIDs = BlockedUserIDs(user)
wheres = []string{}
placeholders = []interface{}{}
cs = []*Comment{}
query = (&Comment{}).Preload()
wheres = []string{}
placeholders = []interface{}{}
)
wheres = append(wheres, "table_name = ? AND table_id = ?")
placeholders = append(placeholders, tableName, tableID)
if len(blockedUserIDs) > 0 {
wheres = append(wheres, "user_id NOT IN ?")
placeholders = append(placeholders, blockedUserIDs)
if !noBlockLists {
blockedUserIDs := BlockedUserIDs(user)
if len(blockedUserIDs) > 0 {
wheres = append(wheres, "user_id NOT IN ?")
placeholders = append(placeholders, blockedUserIDs)
}
}
// Don't show comments from banned or disabled accounts.
@ -172,6 +188,14 @@ func FindPageByComment(user *User, comment *Comment, pageSize int) (int, error)
for i, cid := range allCommentIDs {
if cid == comment.ID {
var page = int(math.Ceil(float64(i) / float64(pageSize)))
// If the comment index is an equal multiple of the page size
// (e.g. comment #20 is the 1st comment on page 2, since 0-19 is page 1),
// account for an off-by-one error.
if i%pageSize == 0 {
page++
}
if page == 0 {
page = 1
}

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
@ -127,7 +134,15 @@ func GetOrphanedCommentPhotos() ([]*CommentPhoto, int64, error) {
ps = []*CommentPhoto{}
)
query := DB.Model(&CommentPhoto{}).Where("comment_id = 0 AND created_at < ?", cutoff)
query := DB.Model(&CommentPhoto{}).Where(`
(comment_id <> 0 AND NOT EXISTS (
SELECT 1 FROM comments
WHERE comments.id = comment_photos.comment_id
))
OR
(comment_id = 0 AND created_at < ?)`,
cutoff,
)
query.Count(&count)
res := query.Limit(500).Find(&ps)
if res.Error != nil {

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
@ -24,6 +36,8 @@ func DeleteUser(user *models.User) error {
// Tables to remove. In case of any unexpected DB errors, these tables are ordered
// to remove the "safest" fields first.
var todo = []remover{
{"Admin group memberships", DeleteAdminGroupUsers},
{"Disown User Forums", DisownForums},
{"Notifications", DeleteNotifications},
{"Likes", DeleteLikes},
{"Threads", DeleteForumThreads},
@ -41,6 +55,11 @@ func DeleteUser(user *models.User) error {
{"Two Factor", DeleteTwoFactor},
{"Profile Fields", DeleteProfile},
{"User Notes", DeleteUserNotes},
{"Change Logs", DeleteChangeLogs},
{"IP Addresses", DeleteIPAddresses},
{"Push Notifications", DeletePushNotifications},
{"Forum Memberships", DeleteForumMemberships},
{"Usage Statistics", DeleteUsageStatistics},
}
for _, item := range todo {
if err := item.Fn(user.ID); err != nil {
@ -52,6 +71,16 @@ func DeleteUser(user *models.User) error {
return user.Delete()
}
// DeleteAdminGroupUsers scrubs data for deleting a user.
func DeleteAdminGroupUsers(userID uint64) error {
log.Error("DeleteUser: DeleteAdminGroupUsers(%d)", userID)
result := models.DB.Exec(
"DELETE FROM admin_group_users WHERE user_id = ?",
userID,
)
return result.Error
}
// DeleteUserPhotos scrubs data for deleting a user.
func DeleteUserPhotos(userID uint64) error {
log.Error("DeleteUser: BEGIN DeleteUserPhotos(%d)", userID)
@ -327,3 +356,64 @@ func DeleteUserNotes(userID uint64) error {
).Delete(&models.UserNote{})
return result.Error
}
// DeleteChangeLogs scrubs data for deleting a user.
func DeleteChangeLogs(userID uint64) error {
log.Error("DeleteUser: DeleteChangeLogs(%d)", userID)
result := models.DB.Where(
"about_user_id = ?",
userID,
).Delete(&models.ChangeLog{})
return result.Error
}
// DeleteIPAddresses scrubs data for deleting a user.
func DeleteIPAddresses(userID uint64) error {
log.Error("DeleteUser: DeleteIPAddresses(%d)", userID)
result := models.DB.Where(
"user_id = ?",
userID,
).Delete(&models.IPAddress{})
return result.Error
}
// DeletePushNotifications scrubs data for deleting a user.
func DeletePushNotifications(userID uint64) error {
log.Error("DeleteUser: DeletePushNotifications(%d)", userID)
result := models.DB.Where(
"user_id = ?",
userID,
).Delete(&models.PushNotification{})
return result.Error
}
// DisownForums unlinks the user from their owned forums.
func DisownForums(userID uint64) error {
log.Error("DeleteUser: DisownForums(%d)", userID)
result := models.DB.Exec(`
UPDATE forums
SET owner_id = NULL
WHERE owner_id = ?
`, userID)
return result.Error
}
// DeleteForumMemberships scrubs data for deleting a user.
func DeleteForumMemberships(userID uint64) error {
log.Error("DeleteUser: DeleteForumMemberships(%d)", userID)
result := models.DB.Where(
"user_id = ?",
userID,
).Delete(&models.ForumMembership{})
return result.Error
}
// DeleteUsageStatistics scrubs data for deleting a user.
func DeleteUsageStatistics(userID uint64) error {
log.Error("DeleteUser: DeleteUsageStatistics(%d)", userID)
result := models.DB.Where(
"user_id = ?",
userID,
).Delete(&models.UsageStatistic{})
return result.Error
}

View File

@ -0,0 +1,196 @@
// Package demographic handles periodic report pulling for high level website statistics.
//
// It powers the home page and insights page, where a prospective new user can get a peek inside
// the website to see the split between regular vs. explicit content and membership statistics.
//
// These database queries could get slow so the demographics are pulled and cached in this package.
package demographic
import (
"encoding/json"
"sort"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/utility"
)
// Demographic is the top level container struct with all the insights needed for front-end display.
type Demographic struct {
Computed bool
LastUpdated time.Time
Photo Photo
People People
}
// Photo statistics show the split between explicit and non-explicit content.
type Photo struct {
Total int64
NonExplicit int64
Explicit int64
}
// People statistics.
type People struct {
Total int64
ExplicitOptIn int64
ExplicitPhoto int64
ByAgeRange map[string]int64
ByGender map[string]int64
ByOrientation map[string]int64
}
// MemberDemographic of members.
type MemberDemographic struct {
Label string // e.g. age range "18-25" or gender
Count int64
Percent string
}
/**
* Dynamic calculation methods on the above types (percentages, etc.)
*/
func (d Demographic) PrettyPrint() string {
b, err := json.MarshalIndent(d, "", "\t")
if err != nil {
return err.Error()
}
return string(b)
}
func (p Photo) PercentExplicit() string {
if p.Total == 0 {
return "0"
}
return utility.FormatFloatToPrecision((float64(p.Explicit)/float64(p.Total))*100, 1)
}
func (p Photo) PercentNonExplicit() string {
if p.Total == 0 {
return "0"
}
return utility.FormatFloatToPrecision((float64(p.NonExplicit)/float64(p.Total))*100, 1)
}
func (p People) PercentExplicit() string {
if p.Total == 0 {
return "0"
}
return utility.FormatFloatToPrecision((float64(p.ExplicitOptIn)/float64(p.Total))*100, 1)
}
func (p People) PercentExplicitPhoto() string {
if p.Total == 0 {
return "0"
}
return utility.FormatFloatToPrecision((float64(p.ExplicitPhoto)/float64(p.Total))*100, 1)
}
func (p People) IterAgeRanges() []MemberDemographic {
var (
result = []MemberDemographic{}
values = []string{}
unique = map[string]struct{}{}
)
for age := range p.ByAgeRange {
if _, ok := unique[age]; !ok {
values = append(values, age)
}
unique[age] = struct{}{}
}
sort.Strings(values)
for _, age := range values {
var (
count = p.ByAgeRange[age]
pct float64
)
if p.Total > 0 {
pct = ((float64(count) / float64(p.Total)) * 100)
}
result = append(result, MemberDemographic{
Label: age,
Count: p.ByAgeRange[age],
Percent: utility.FormatFloatToPrecision(pct, 1),
})
}
return result
}
func (p People) IterGenders() []MemberDemographic {
var (
result = []MemberDemographic{}
values = append(config.Gender, "")
unique = map[string]struct{}{}
)
for _, option := range values {
unique[option] = struct{}{}
}
for gender := range p.ByGender {
if _, ok := unique[gender]; !ok {
values = append(values, gender)
unique[gender] = struct{}{}
}
}
for _, gender := range values {
var (
count = p.ByGender[gender]
pct float64
)
if p.Total > 0 {
pct = ((float64(count) / float64(p.Total)) * 100)
}
result = append(result, MemberDemographic{
Label: gender,
Count: p.ByGender[gender],
Percent: utility.FormatFloatToPrecision(pct, 1),
})
}
return result
}
func (p People) IterOrientations() []MemberDemographic {
var (
result = []MemberDemographic{}
values = append(config.Orientation, "")
unique = map[string]struct{}{}
)
for _, option := range values {
unique[option] = struct{}{}
}
for orientation := range p.ByOrientation {
if _, ok := unique[orientation]; !ok {
values = append(values, orientation)
unique[orientation] = struct{}{}
}
}
for _, gender := range values {
var (
count = p.ByOrientation[gender]
pct float64
)
if p.Total > 0 {
pct = ((float64(count) / float64(p.Total)) * 100)
}
result = append(result, MemberDemographic{
Label: gender,
Count: p.ByOrientation[gender],
Percent: utility.FormatFloatToPrecision(pct, 1),
})
}
return result
}

View File

@ -0,0 +1,302 @@
package demographic
import (
"errors"
"sync"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
)
// Cached statistics (in case the queries are heavy to hit too often).
var (
cachedDemographic Demographic
cacheMu sync.Mutex
)
// Get the current cached demographics result.
func Get() (Demographic, error) {
// Do we have the results cached?
var result = cachedDemographic
if !result.Computed || time.Since(result.LastUpdated) > config.DemographicsCacheTTL {
cacheMu.Lock()
defer cacheMu.Unlock()
// If we have a race of threads: e.g. one request is pulling the stats and the second is locked.
// Check if we have an updated result from the first thread.
if time.Since(cachedDemographic.LastUpdated) < config.DemographicsCacheTTL {
return cachedDemographic, nil
}
// Get the latest.
res, err := Generate()
if err != nil {
return result, err
}
cachedDemographic = res
}
return cachedDemographic, nil
}
// Refresh the demographics cache, pulling fresh results from the database every time.
func Refresh() (Demographic, error) {
cacheMu.Lock()
cachedDemographic = Demographic{}
cacheMu.Unlock()
return Get()
}
// Generate the demographics result.
func Generate() (Demographic, error) {
if !config.Current.Database.IsPostgres {
return cachedDemographic, errors.New("this feature requires a PostgreSQL database")
}
result := Demographic{
Computed: true,
LastUpdated: time.Now(),
Photo: PhotoStatistics(),
People: PeopleStatistics(),
}
return result, nil
}
// PeopleStatistics pulls various metrics about users of the website.
func PeopleStatistics() People {
var result = People{
ByAgeRange: map[string]int64{},
ByGender: map[string]int64{"": 0},
ByOrientation: map[string]int64{"": 0},
}
type record struct {
MetricType string
MetricValue string
MetricCount int64
}
var records []record
res := models.DB.Raw(`
-- Users who opt in/out of explicit content
WITH subquery_explicit AS (
SELECT
SUM(CASE WHEN explicit IS TRUE THEN 1 ELSE 0 END) AS explicit_count,
SUM(CASE WHEN explicit IS NOT TRUE THEN 1 ELSE 0 END) AS non_explicit_count
FROM users
WHERE users.status = 'active'
AND users.certified IS TRUE
),
-- Users who share at least one explicit photo on public
subquery_explicit_photo AS (
SELECT
COUNT(*) AS user_count
FROM users
WHERE users.status = 'active'
AND users.certified IS TRUE
AND EXISTS (
SELECT 1
FROM photos
WHERE photos.user_id = users.id
AND photos.explicit IS TRUE
AND photos.visibility = 'public'
)
),
-- User counts by age
subquery_ages AS (
SELECT
CASE
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 0 AND 25 THEN '18-25'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 26 and 35 THEN '26-35'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 36 and 45 THEN '36-45'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 46 and 55 THEN '46-55'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 56 and 65 THEN '56-65'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 66 and 75 THEN '66-75'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 76 and 85 THEN '76-85'
ELSE '86+'
END AS age_range,
COUNT(*) AS user_count
FROM
users
WHERE users.status = 'active'
AND users.certified IS TRUE
GROUP BY
CASE
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 0 AND 25 THEN '18-25'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 26 and 35 THEN '26-35'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 36 and 45 THEN '36-45'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 46 and 55 THEN '46-55'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 56 and 65 THEN '56-65'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 66 and 75 THEN '66-75'
WHEN DATE_PART('year', AGE(birthdate)) BETWEEN 76 and 85 THEN '76-85'
ELSE '86+'
END
),
-- User counts by gender
subquery_gender AS (
SELECT
profile_fields.value AS gender,
COUNT(*) AS user_count
FROM users
JOIN profile_fields ON profile_fields.user_id = users.id
WHERE users.status = 'active'
AND users.certified IS TRUE
AND profile_fields.name = 'gender'
GROUP BY profile_fields.value
),
-- User counts by orientation
subquery_orientation AS (
SELECT
profile_fields.value AS orientation,
COUNT(*) AS user_count
FROM users
JOIN profile_fields ON profile_fields.user_id = users.id
WHERE users.status = 'active'
AND users.certified IS TRUE
AND profile_fields.name = 'orientation'
GROUP BY profile_fields.value
)
SELECT
'ExplicitCount' AS metric_type,
'explicit' AS metric_value,
explicit_count AS metric_count
FROM subquery_explicit
UNION ALL
SELECT
'ExplicitPhotoCount' AS metric_type,
'count' AS metric_value,
user_count AS metric_count
FROM subquery_explicit_photo
UNION ALL
SELECT
'ExplicitCount' AS metric_type,
'non_explicit' AS metric_value,
non_explicit_count AS metric_count
FROM subquery_explicit
UNION ALL
SELECT
'AgeCounts' AS metric_type,
age_range AS metric_value,
user_count AS metric_count
FROM subquery_ages
UNION ALL
SELECT
'GenderCount' AS metric_type,
gender AS metric_value,
user_count AS metric_count
FROM subquery_gender
UNION ALL
SELECT
'OrientationCount' AS metric_type,
orientation AS metric_value,
user_count AS metric_count
FROM subquery_orientation
`).Scan(&records)
if res.Error != nil {
log.Error("PeopleStatistics: %s", res.Error)
return result
}
// Ingest the records.
var (
totalWithAge int64 // will be the total count of users since age is required
totalWithGender int64
totalWithOrientation int64
)
for _, row := range records {
switch row.MetricType {
case "ExplicitCount":
result.Total += row.MetricCount
if row.MetricValue == "explicit" {
result.ExplicitOptIn = row.MetricCount
}
case "ExplicitPhotoCount":
result.ExplicitPhoto = row.MetricCount
case "AgeCounts":
if _, ok := result.ByAgeRange[row.MetricValue]; !ok {
result.ByAgeRange[row.MetricValue] = 0
}
result.ByAgeRange[row.MetricValue] += row.MetricCount
totalWithAge += row.MetricCount
case "GenderCount":
if _, ok := result.ByGender[row.MetricValue]; !ok {
result.ByGender[row.MetricValue] = 0
}
result.ByGender[row.MetricValue] += row.MetricCount
totalWithGender += row.MetricCount
case "OrientationCount":
if _, ok := result.ByOrientation[row.MetricValue]; !ok {
result.ByOrientation[row.MetricValue] = 0
}
result.ByOrientation[row.MetricValue] += row.MetricCount
totalWithOrientation += row.MetricCount
}
}
// Gender and Orientation: pad out the "no answer" selection with the count of users
// who had no profile_fields stored in the DB at all.
result.ByOrientation[""] += (totalWithAge - totalWithOrientation)
result.ByGender[""] += (totalWithAge - totalWithGender)
return result
}
// PhotoStatistics gets info about photo usage on the website.
//
// Counts of Explicit vs. Non-Explicit photos.
func PhotoStatistics() Photo {
var result Photo
type record struct {
Explicit bool
C int64
}
var records []record
res := models.DB.Raw(`
SELECT
photos.explicit,
count(photos.id) AS c
FROM
photos
JOIN users ON (photos.user_id = users.id)
WHERE photos.visibility = 'public'
AND photos.gallery IS TRUE
AND users.certified IS TRUE
AND users.status = 'active'
GROUP BY photos.explicit
ORDER BY c DESC
`).Scan(&records)
if res.Error != nil {
log.Error("PhotoStatistics: %s", res.Error)
return result
}
for _, row := range records {
result.Total += row.C
if row.Explicit {
result.Explicit += row.C
} else {
result.NonExplicit += row.C
}
}
return result
}

View File

@ -19,28 +19,33 @@ func ExportModels(zw *zip.Writer, user *models.User) error {
// List of tables to export. Keep the ordering in sync with
// the AutoMigrate() calls in ../models.go
var todo = []task{
{"User", ExportUserTable},
// Note: AdminGroup info is eager-loaded in User export
{"Block", ExportBlockTable},
{"CertificationPhoto", ExportCertificationPhotoTable},
{"ChangeLog", ExportChangeLogTable},
{"Comment", ExportCommentTable},
{"CommentPhoto", ExportCommentPhotoTable},
{"Feedback", ExportFeedbackTable},
{"ForumMembership", ExportForumMembershipTable},
{"Friend", ExportFriendTable},
{"Forum", ExportForumTable},
{"IPAddress", ExportIPAddressTable},
{"Like", ExportLikeTable},
{"Message", ExportMessageTable},
{"Notification", ExportNotificationTable},
{"ProfileField", ExportProfileFieldTable},
{"Photo", ExportPhotoTable},
{"PrivatePhoto", ExportPrivatePhotoTable},
{"CertificationPhoto", ExportCertificationPhotoTable},
{"Message", ExportMessageTable},
{"Friend", ExportFriendTable},
{"Block", ExportBlockTable},
{"Feedback", ExportFeedbackTable},
{"Forum", ExportForumTable},
{"Thread", ExportThreadTable},
{"Comment", ExportCommentTable},
{"Like", ExportLikeTable},
{"Notification", ExportNotificationTable},
{"Subscription", ExportSubscriptionTable},
{"CommentPhoto", ExportCommentPhotoTable},
// Note: Poll table is eager-loaded in Thread export
{"PollVote", ExportPollVoteTable},
// Note: AdminGroup info is eager-loaded in User export
{"PrivatePhoto", ExportPrivatePhotoTable},
{"PushNotification", ExportPushNotificationTable},
{"Subscription", ExportSubscriptionTable},
{"Thread", ExportThreadTable},
{"TwoFactor", ExportTwoFactorTable},
{"UsageStatistic", ExportUsageStatisticTable},
{"User", ExportUserTable},
{"UserLocation", ExportUserLocationTable},
{"UserNote", ExportUserNoteTable},
{"TwoFactor", ExportTwoFactorTable},
}
for _, item := range todo {
log.Info("Exporting data model: %s", item.Step)
@ -383,6 +388,21 @@ func ExportUserNoteTable(zw *zip.Writer, user *models.User) error {
return ZipJson(zw, "user_notes.json", items)
}
func ExportChangeLogTable(zw *zip.Writer, user *models.User) error {
var (
items = []*models.ChangeLog{}
query = models.DB.Model(&models.ChangeLog{}).Where(
"about_user_id = ? OR admin_user_id = ?",
user.ID, user.ID,
).Find(&items)
)
if query.Error != nil {
return query.Error
}
return ZipJson(zw, "change_logs.json", items)
}
func ExportUserLocationTable(zw *zip.Writer, user *models.User) error {
var (
items = []*models.UserLocation{}
@ -412,3 +432,63 @@ func ExportTwoFactorTable(zw *zip.Writer, user *models.User) error {
return ZipJson(zw, "two_factor.json", items)
}
func ExportIPAddressTable(zw *zip.Writer, user *models.User) error {
var (
items = []*models.IPAddress{}
query = models.DB.Model(&models.IPAddress{}).Where(
"user_id = ?",
user.ID,
).Find(&items)
)
if query.Error != nil {
return query.Error
}
return ZipJson(zw, "ip_addresses.json", items)
}
func ExportForumMembershipTable(zw *zip.Writer, user *models.User) error {
var (
items = []*models.ForumMembership{}
query = models.DB.Model(&models.ForumMembership{}).Where(
"user_id = ?",
user.ID,
).Find(&items)
)
if query.Error != nil {
return query.Error
}
return ZipJson(zw, "forum_memberships.json", items)
}
func ExportPushNotificationTable(zw *zip.Writer, user *models.User) error {
var (
items = []*models.PushNotification{}
query = models.DB.Model(&models.PushNotification{}).Where(
"user_id = ?",
user.ID,
).Find(&items)
)
if query.Error != nil {
return query.Error
}
return ZipJson(zw, "push_notifications.json", items)
}
func ExportUsageStatisticTable(zw *zip.Writer, user *models.User) error {
var (
items = []*models.UsageStatistic{}
query = models.DB.Model(&models.UsageStatistic{}).Where(
"user_id = ?",
user.ID,
).Find(&items)
)
if query.Error != nil {
return query.Error
}
return ZipJson(zw, "usage_statistics.json", items)
}

View File

@ -1,6 +1,7 @@
package models
import (
"sort"
"strings"
"time"
@ -11,6 +12,7 @@ import (
type Feedback struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"index"` // if logged-in user posted this
AboutUserID uint64 // associated 'about' user (e.g., owner of a reported photo)
Acknowledged bool `gorm:"index"` // admin dashboard "read" status
Intent string
Subject string
@ -45,7 +47,7 @@ func CountUnreadFeedback() int64 {
}
// PaginateFeedback
func PaginateFeedback(acknowledged bool, intent string, pager *Pagination) ([]*Feedback, error) {
func PaginateFeedback(acknowledged bool, intent, subject string, search *Search, pager *Pagination) ([]*Feedback, error) {
var (
fb = []*Feedback{}
wheres = []string{}
@ -60,6 +62,23 @@ func PaginateFeedback(acknowledged bool, intent string, pager *Pagination) ([]*F
placeholders = append(placeholders, intent)
}
if subject != "" {
wheres = append(wheres, "subject = ?")
placeholders = append(placeholders, subject)
}
// Search terms.
for _, term := range search.Includes {
var ilike = "%" + strings.ToLower(term) + "%"
wheres = append(wheres, "message ILIKE ?")
placeholders = append(placeholders, ilike)
}
for _, term := range search.Excludes {
var ilike = "%" + strings.ToLower(term) + "%"
wheres = append(wheres, "message NOT ILIKE ?")
placeholders = append(placeholders, ilike)
}
query := DB.Where(
strings.Join(wheres, " AND "),
placeholders...,
@ -81,19 +100,49 @@ func PaginateFeedback(acknowledged bool, intent string, pager *Pagination) ([]*F
// It returns reports where table_name=users and their user ID, or where table_name=photos and about any
// of their current photo IDs. Additionally, it will look for chat room reports which were about their
// username.
func PaginateFeedbackAboutUser(user *User, pager *Pagination) ([]*Feedback, error) {
//
// The 'show' parameter applies some basic filter choices:
//
// - Blank string (default) = all reports From or About this user
// - "about" = all reports About this user (by table_name=users table_id=userID, or table_name=photos
// for any of their existing photo IDs)
// - "from" = all reports From this user (where reporting user_id is the user's ID)
// - "fuzzy" = fuzzy full text search on all reports that contain the user's username.
func PaginateFeedbackAboutUser(user *User, show string, pager *Pagination) ([]*Feedback, error) {
var (
fb = []*Feedback{}
photoIDs, _ = user.AllPhotoIDs()
wheres = []string{}
placeholders = []interface{}{}
like = "%" + user.Username + "%"
)
wheres = append(wheres, `
(table_name = 'users' AND table_id = ?) OR
(table_name = 'photos' AND table_id IN ?)
`)
placeholders = append(placeholders, user.ID, photoIDs)
// How to apply the search filters?
switch show {
case "about":
wheres = append(wheres, `
about_user_id = ? OR
(table_name = 'users' AND table_id = ?) OR
(table_name = 'photos' AND table_id IN ?)
`)
placeholders = append(placeholders, user.ID, user.ID, photoIDs)
case "from":
wheres = append(wheres, "user_id = ?")
placeholders = append(placeholders, user.ID)
case "fuzzy":
wheres = append(wheres, "message LIKE ?")
placeholders = append(placeholders, like)
default:
// Default=everything.
wheres = append(wheres, `
user_id = ? OR
about_user_id = ? OR
(table_name = 'users' AND table_id = ?) OR
(table_name = 'photos' AND table_id IN ?) OR
message LIKE ?
`)
placeholders = append(placeholders, user.ID, user.ID, user.ID, photoIDs, like)
}
query := DB.Where(
strings.Join(wheres, " AND "),
@ -111,6 +160,22 @@ func PaginateFeedbackAboutUser(user *User, pager *Pagination) ([]*Feedback, erro
return fb, result.Error
}
// DistinctFeedbackSubjects returns the distinct subjects on feedback & reports.
func DistinctFeedbackSubjects() []string {
var results = []string{}
query := DB.Model(&Feedback{}).
Select("DISTINCT feedbacks.subject").
Group("feedbacks.subject").
Find(&results)
if query.Error != nil {
log.Error("DistinctFeedbackSubjects: %s", query.Error)
return nil
}
sort.Strings(results)
return results
}
// CreateFeedback saves a new Feedback row to the DB.
func CreateFeedback(fb *Feedback) error {
result := DB.Create(fb)

View File

@ -5,6 +5,7 @@ import (
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"gorm.io/gorm"
)
@ -20,14 +21,14 @@ type Forum struct {
Explicit bool `gorm:"index"`
Privileged bool
PermitPhotos bool
InnerCircle bool
Private bool `gorm:"index"`
CreatedAt time.Time
UpdatedAt time.Time
}
// Preload related tables for the forum (classmethod).
func (f *Forum) Preload() *gorm.DB {
return DB.Preload("Owner")
return DB.Preload("Owner").Preload("Owner.ProfilePhoto")
}
// GetForum by ID.
@ -69,6 +70,13 @@ func ForumByFragment(fragment string) (*Forum, error) {
return f, result.Error
}
// CanEdit checks if the user has edit rights over this forum.
//
// That is, they are its Owner or they are an admin with Manage Forums permission.
func (f *Forum) CanEdit(user *User) bool {
return user.HasAdminScope(config.ScopeForumAdmin) || f.OwnerID == user.ID
}
/*
PaginateForums scans over the available forums for a user.
@ -77,8 +85,15 @@ Parameters:
- userID: of who is looking
- categories: optional, filter within categories
- pager
The pager Sort accepts a couple of custom values for more advanced sorting:
- by_latest: recently updated posts
- by_threads: thread count
- by_posts: post count
- by_users: user count
*/
func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Forum, error) {
func PaginateForums(user *User, categories []string, search *Search, subscribed bool, pager *Pagination) ([]*Forum, error) {
var (
fs = []*Forum{}
query = (&Forum{}).Preload()
@ -96,9 +111,55 @@ func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Foru
wheres = append(wheres, "explicit = false")
}
// Hide circle forums if the user isn't in the circle.
if !user.IsInnerCircle() {
wheres = append(wheres, "inner_circle is not true")
// Hide private forums except for admins and approved users.
if !user.IsAdmin {
wheres = append(wheres, `
(
private IS NOT TRUE
OR EXISTS (
SELECT 1
FROM forum_memberships
WHERE forum_id = forums.id
AND user_id = ?
AND (
is_moderator IS TRUE
OR approved IS TRUE
)
)
)`,
)
placeholders = append(placeholders, user.ID)
}
// Followed forums only? (for the My List category on home page)
if subscribed {
wheres = append(wheres, `
EXISTS (
SELECT 1
FROM forum_memberships
WHERE user_id = ?
AND forum_id = forums.id
)
OR (
forums.owner_id = ?
AND (forums.category = '' OR forums.category IS NULL)
)
`)
placeholders = append(placeholders, user.ID, user.ID)
}
// Apply their search terms.
if search != nil {
for _, term := range search.Includes {
var ilike = "%" + strings.ToLower(term) + "%"
wheres = append(wheres, "(fragment ILIKE ? OR title ILIKE ? OR description ILIKE ?)")
placeholders = append(placeholders, ilike, ilike, ilike)
}
for _, term := range search.Excludes {
var ilike = "%" + strings.ToLower(term) + "%"
wheres = append(wheres, "(fragment NOT ILIKE ? AND title NOT ILIKE ? AND description NOT ILIKE ?)")
placeholders = append(placeholders, ilike, ilike, ilike)
}
}
// Filters?
@ -109,6 +170,43 @@ func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Foru
)
}
// Custom SORT parameters.
switch pager.Sort {
case "by_followers":
pager.Sort = `(
SELECT count(forum_memberships.id)
FROM forum_memberships
WHERE forum_memberships.forum_id = forums.id
) DESC`
case "by_latest":
pager.Sort = `(
SELECT MAX(threads.updated_at)
FROM threads
WHERE threads.forum_id = forums.id
) DESC NULLS LAST`
case "by_threads":
pager.Sort = `(
SELECT count(threads.id)
FROM threads
WHERE threads.forum_id = forums.id
) DESC`
case "by_posts":
pager.Sort = `(
SELECT count(comments.id)
FROM threads
JOIN comments ON comments.table_name='threads' AND comments.table_id=threads.id
WHERE threads.forum_id = forums.id
) DESC`
case "by_users":
pager.Sort = `(
SELECT count(distinct(users.id))
FROM threads
JOIN comments ON comments.table_name='threads' AND comments.table_id=threads.id
JOIN users ON comments.user_id=users.id
WHERE threads.forum_id = forums.id
) DESC`
}
query = query.Order(pager.Sort)
query.Model(&Forum{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
@ -116,20 +214,44 @@ func PaginateForums(user *User, categories []string, pager *Pagination) ([]*Foru
}
// PaginateOwnedForums returns forums the user owns (or all forums to admins).
func PaginateOwnedForums(userID uint64, isAdmin bool, pager *Pagination) ([]*Forum, error) {
func PaginateOwnedForums(userID uint64, isAdmin bool, categories []string, search *Search, pager *Pagination) ([]*Forum, error) {
var (
fs = []*Forum{}
query = (&Forum{}).Preload()
fs = []*Forum{}
query = (&Forum{}).Preload()
wheres = []string{}
placeholders = []interface{}{}
)
// Users see only their owned forums.
if !isAdmin {
query = query.Where(
"owner_id = ?",
userID,
)
wheres = append(wheres, "owner_id = ?")
placeholders = append(placeholders, userID)
}
query = query.Order(pager.Sort)
if len(categories) > 0 {
wheres = append(wheres, "category IN ?")
placeholders = append(placeholders, categories)
}
// Apply their search terms.
if search != nil {
for _, term := range search.Includes {
var ilike = "%" + strings.ToLower(term) + "%"
wheres = append(wheres, "(fragment ILIKE ? OR title ILIKE ? OR description ILIKE ?)")
placeholders = append(placeholders, ilike, ilike, ilike)
}
for _, term := range search.Excludes {
var ilike = "%" + strings.ToLower(term) + "%"
wheres = append(wheres, "(fragment NOT ILIKE ? AND title NOT ILIKE ? AND description NOT ILIKE ?)")
placeholders = append(placeholders, ilike, ilike, ilike)
}
}
query = query.Where(
strings.Join(wheres, " AND "),
placeholders...,
).Order(pager.Sort)
query.Model(&Forum{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
return fs, result.Error
@ -159,6 +281,15 @@ func CategorizeForums(fs []*Forum, categories []string) []*CategorizedForum {
idxMap = map[string]int{}
)
// Forum Browse page: we are not grouping by categories but still need at least one.
if len(categories) == 0 {
return []*CategorizedForum{
{
Forums: fs,
},
}
}
// Initialize the result set.
for i, category := range categories {
result = append(result, &CategorizedForum{

View File

@ -0,0 +1,291 @@
package models
import (
"errors"
"strings"
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"gorm.io/gorm"
)
// ForumMembership table.
//
// Unique key constraint pairs user_id and forum_id.
type ForumMembership struct {
ID uint64 `gorm:"primaryKey"`
UserID uint64 `gorm:"uniqueIndex:idx_forum_membership"`
User User `gorm:"foreignKey:user_id"`
ForumID uint64 `gorm:"uniqueIndex:idx_forum_membership"`
Forum Forum `gorm:"foreignKey:forum_id"`
Approved bool `gorm:"index"`
IsModerator bool `gorm:"index"`
CreatedAt time.Time
UpdatedAt time.Time
}
// Preload related tables for the forum (classmethod).
func (f *ForumMembership) Preload() *gorm.DB {
return DB.Preload("User").Preload("Forum")
}
// CreateForumMembership subscribes the user to a forum.
func CreateForumMembership(user *User, forum *Forum) (*ForumMembership, error) {
var (
f = &ForumMembership{
User: *user,
Forum: *forum,
Approved: true,
}
result = DB.Create(f)
)
return f, result.Error
}
// GetForumMembership looks up a forum membership, returning an error if one is not found.
func GetForumMembership(user *User, forum *Forum) (*ForumMembership, error) {
var (
f = &ForumMembership{}
result = f.Preload().Where(
"user_id = ? AND forum_id = ?",
user.ID, forum.ID,
).First(&f)
)
return f, result.Error
}
// AddModerator appoints a moderator to the forum, returning that user's ForumMembership.
//
// If the target is not following the forum, a ForumMembership is created, marked as a moderator and returned.
func (f *Forum) AddModerator(user *User) (*ForumMembership, error) {
var fm *ForumMembership
if found, err := GetForumMembership(user, f); err != nil {
fm = &ForumMembership{
User: *user,
Forum: *f,
Approved: true,
}
} else {
fm = found
}
// They are already a moderator?
if fm.IsModerator {
return fm, errors.New("they are already a moderator of this forum")
}
fm.IsModerator = true
err := fm.Save()
return fm, err
}
// CanBeSeenBy checks whether the user can see a private forum.
//
// Admins, owners, moderators and approved followers can see it.
//
// Note: this may invoke a DB query to check for moderator.
func (f *Forum) CanBeSeenBy(user *User) bool {
if !f.Private || user.IsAdmin || user.ID == f.OwnerID {
return true
}
if fm, err := GetForumMembership(user, f); err == nil {
return fm.Approved || fm.IsModerator
}
return false
}
// CanBeModeratedBy checks whether the user can moderate this forum.
//
// Admins, owners and moderators can do so.
//
// Note: this may invoke a DB query to check for moderator.
func (f *Forum) CanBeModeratedBy(user *User) bool {
if user.HasAdminScope(config.ScopeForumModerator) || f.OwnerID == user.ID {
return true
}
if fm, err := GetForumMembership(user, f); err == nil {
return fm.IsModerator
}
return false
}
// RemoveModerator will unset a user's moderator flag on this forum.
func (f *Forum) RemoveModerator(user *User) (*ForumMembership, error) {
fm, err := GetForumMembership(user, f)
if err != nil {
return nil, err
}
fm.IsModerator = false
err = fm.Save()
return fm, err
}
// GetModerators loads all of the moderators of a forum, ordered alphabetically by username.
func (f *Forum) GetModerators() ([]*User, error) {
// Find all forum memberships that moderate us.
var (
fm = []*ForumMembership{}
result = (&ForumMembership{}).Preload().Where(
"forum_id = ? AND is_moderator IS TRUE",
f.ID,
).Find(&fm)
)
if result.Error != nil {
log.Error("Forum(%d).GetModerators(): %s", f.ID, result.Error)
return nil, nil
}
// Load these users.
var userIDs = []uint64{}
for _, row := range fm {
userIDs = append(userIDs, row.UserID)
}
return GetUsersAlphabetically(userIDs)
}
// IsForumSubscribed checks if the current user subscribes to this forum.
func IsForumSubscribed(user *User, forum *Forum) bool {
f, _ := GetForumMembership(user, forum)
return f.UserID == user.ID
}
// HasForumSubscriptions returns if the current user has at least one forum membership.
func (u *User) HasForumSubscriptions() bool {
var count int64
DB.Model(&ForumMembership{}).Where(
"user_id = ?",
u.ID,
).Count(&count)
return count > 0
}
// CountForumMemberships counts how many subscribers a forum has.
func CountForumMemberships(forum *Forum) int64 {
var count int64
DB.Model(&ForumMembership{}).Where(
"forum_id = ?",
forum.ID,
).Count(&count)
return count
}
// Save a forum membership.
func (f *ForumMembership) Save() error {
return DB.Save(f).Error
}
// Delete a forum membership.
func (f *ForumMembership) Delete() error {
return DB.Delete(f).Error
}
// PaginateForumMemberships paginates over a user's ForumMemberships.
func PaginateForumMemberships(user *User, pager *Pagination) ([]*ForumMembership, error) {
var (
fs = []*ForumMembership{}
query = (&ForumMembership{}).Preload()
wheres = []string{}
placeholders = []interface{}{}
)
query = query.Where(
strings.Join(wheres, " AND "),
placeholders...,
).Order(pager.Sort)
query.Model(&ForumMembership{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&fs)
return fs, result.Error
}
// ForumMembershipMap maps table IDs to Likes metadata.
type ForumMembershipMap map[uint64]bool
// Get like stats from the map.
func (fm ForumMembershipMap) Get(id uint64) bool {
return fm[id]
}
// MapForumMemberships looks up a user's memberships in bulk.
func MapForumMemberships(user *User, forums []*Forum) ForumMembershipMap {
var (
result = ForumMembershipMap{}
forumIDs = []uint64{}
)
// Initialize the result set.
for _, forum := range forums {
result[forum.ID] = false
forumIDs = append(forumIDs, forum.ID)
}
// Map the forum IDs the user subscribes to.
var followIDs = []uint64{}
if res := DB.Model(&ForumMembership{}).Select(
"forum_id",
).Where(
"user_id = ? AND forum_id IN ?",
user.ID, forumIDs,
).Scan(&followIDs); res.Error != nil {
log.Error("MapForumMemberships: %s", res.Error)
}
for _, forumID := range followIDs {
result[forumID] = true
}
return result
}
// ForumFollowerMap maps table IDs to counts of memberships.
type ForumFollowerMap map[uint64]int64
// Get like stats from the map.
func (fm ForumFollowerMap) Get(id uint64) int64 {
return fm[id]
}
// MapForumFollowers maps out the count of followers for a set of forums.
func MapForumFollowers(forums []*Forum) ForumFollowerMap {
var (
result = ForumFollowerMap{}
forumIDs = []uint64{}
)
// Initialize the result set.
for _, forum := range forums {
forumIDs = append(forumIDs, forum.ID)
}
// Hold the result of the grouped count query.
type group struct {
ID uint64
Followers int64
}
var groups = []group{}
// Map the counts of likes to each of these IDs.
if res := DB.Model(
&ForumMembership{},
).Select(
"forum_id AS id, count(id) AS followers",
).Where(
"forum_id IN ?",
forumIDs,
).Group("forum_id").Scan(&groups); res.Error != nil {
log.Error("MapLikes: count query: %s", res.Error)
}
for _, row := range groups {
result[row.ID] = row.Followers
}
return result
}

67
pkg/models/forum_quota.go Normal file
View File

@ -0,0 +1,67 @@
package models
import (
"time"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
)
// Functions dealing with quota allowances for user-created forums.
// ComputeForumQuota returns a count of how many user-created forums this user is allowed to own.
//
// The forum quota slowly increases over time and quantity of forum posts written by this user.
func ComputeForumQuota(user *User) int64 {
var (
credits int64
numPosts = CountCommentsByUser(user, "threads")
)
// Get the user's certification date. They get one credit for having a long standing
// certified status on their account.
if certSince, err := user.CertifiedSince(); err != nil {
return 0
} else if time.Since(certSince) > config.UserForumQuotaCertLifetimeDays {
credits++
}
// Take their number of posts and compute their quota.
var schedule int64
for _, schedule = range config.UserForumQuotaCommentCountSchedule {
if numPosts > schedule {
credits++
numPosts -= schedule
}
}
// If they still have posts, repeat the final schedule per credit.
for numPosts > schedule {
credits++
numPosts -= schedule
}
return credits
}
// ForumQuotaRemaining computes the amount of additional forums the current user can create.
func (u *User) ForumQuotaRemaining() int64 {
var (
quota = ComputeForumQuota(u)
owned = CountOwnedUserForums(u)
)
return quota - owned
}
// CountOwnedUserForums returns the total number of user forums owned by the given user.
func CountOwnedUserForums(user *User) int64 {
var count int64
result := DB.Model(&Forum{}).Where(
"owner_id = ? AND (category='' OR category IS NULL)",
user.ID,
).Count(&count)
if result.Error != nil {
log.Error("CountOwnedUserForums(%d): %s", user.ID, result.Error)
}
return count
}

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

View File

@ -81,7 +81,7 @@ type ForumSearchFilters struct {
}
// SearchForum searches the forum.
func SearchForum(user *User, search *Search, filters ForumSearchFilters, pager *Pagination) ([]*Comment, error) {
func SearchForum(user *User, categories []string, search *Search, filters ForumSearchFilters, pager *Pagination) ([]*Comment, error) {
var (
coms = []*Comment{}
query = (&Comment{}).Preload()
@ -90,14 +90,19 @@ func SearchForum(user *User, search *Search, filters ForumSearchFilters, pager *
placeholders = []interface{}{}
)
if len(categories) > 0 {
wheres = append(wheres, "category IN ?")
placeholders = append(placeholders, categories)
}
// Hide explicit forum if user hasn't opted into it.
if !user.Explicit && !user.IsAdmin {
wheres = append(wheres, "forums.explicit = false")
}
// Circle membership.
if !user.IsInnerCircle() {
wheres = append(wheres, "forums.inner_circle is not true")
// Private forums.
if !user.IsAdmin {
wheres = append(wheres, "forums.private is not true")
}
// Blocked users?

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)

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