diff --git a/README.md b/README.md index 0ab01b4..b6ef11f 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,10 @@ Run `nonshy --help` for its documentation. Run `nonshy web` to start the web server. +```bash +nonshy web --host 0.0.0.0 --port 8080 --debug +``` + ## Create Admin User Accounts Use the `nonshy user add` command like so: @@ -70,6 +74,44 @@ $ nonshy user add --admin \ Shorthand options `-e`, `-p` and `-u` can work in place of the longer options `--email`, `--password` and `--username` respectively. +After the first admin user is created, you may promote other users thru +the web app by using the admin controls on their profile page. + +## A Brief Tour of the Code + +* `cmd/nonshy/main.go`: the entry point for the Go program. +* `pkg/webserver.go`: the entry point for the web server. +* `pkg/config`: mostly hard-coded configuration values - all of the page + sizes and business logic controls are in here, set at compile time. For + ease of local development you may want to toggle SkipEmailValidation in + here - the signup form will then directly allow full signup with a user + and password. +* `pkg/controller`: the various web endpoint controllers are here, + categorized into subpackages (account, forum, inbox, photo, etc.) +* `pkg/log`: the logging to terminal functions. +* `pkg/mail`: functions for delivering HTML email messages. +* `pkg/markdown`: functions to render GitHub Flavored Markdown. +* `pkg/middleware`: HTTP middleware functions, for things such as: + * Session cookies + * Authentication (LoginRequired, AdminRequired) + * CSRF protection + * Logging HTTP requests + * Panic recovery for unhandled server errors +* `pkg/models`: the SQL database models and query functions are here. + * `pkg/models/deletion`: the code to fully scrub wipe data for + user deletion (GDPR/CCPA compliance). +* `pkg/photo`: photo management functions: handle uploads, scale and + crop, generate URLs and deletion. +* `pkg/ratelimit`: rate limiter for login attempts etc. +* `pkg/redis`: Redis cache functions - get/set JSON values for things like + session cookie storage and temporary rate limits. +* `pkg/router`: the HTTP route URLs for the controllers are here. +* `pkg/session`: functions to read/write the user's session cookie + (log in/out, get current user, flash messages) +* `pkg/templates`: functions to handle HTTP responses - render HTML + templates, issue redirects, error pages, ... +* `pkg/utility`: miscellaneous useful functions for the app. + ## License GPLv3. \ No newline at end of file diff --git a/pkg/controller/comment/post_comment.go b/pkg/controller/comment/post_comment.go index 209f16e..36ead85 100644 --- a/pkg/controller/comment/post_comment.go +++ b/pkg/controller/comment/post_comment.go @@ -178,6 +178,39 @@ func PostComment() http.HandlerFunc { log.Error("Couldn't create Comment notification: %s", err) } } + + // Notify subscribers to this comment thread. + for _, userID := range models.GetSubscribers(comment.TableName, comment.TableID) { + if notifyUser != nil && userID == notifyUser.ID { + // Don't notify the recipient twice. + continue + } else if userID == currentUser.ID { + // Don't notify the poster of the comment. + continue + } + + notif := &models.Notification{ + UserID: userID, + AboutUser: *currentUser, + Type: models.NotificationAlsoCommented, + TableName: comment.TableName, + TableID: comment.TableID, + Message: message, + Link: fromURL, + } + if err := models.CreateNotification(notif); err != nil { + log.Error("Couldn't create Comment notification for subscriber %d: %s", userID, err) + } + } + + // Subscribe the current user to this comment thread, so they are + // notified if other users add followup comments. + if _, err := models.SubscribeTo(currentUser, comment.TableName, comment.TableID); err != nil { + log.Error("Couldn't subscribe user %d to comment thread %s/%d: %s", + currentUser.ID, comment.TableName, comment.TableID, err, + ) + } + return } } diff --git a/pkg/controller/comment/subscription.go b/pkg/controller/comment/subscription.go new file mode 100644 index 0000000..cb063ee --- /dev/null +++ b/pkg/controller/comment/subscription.go @@ -0,0 +1,91 @@ +package comment + +import ( + "net/http" + "net/url" + "strconv" + "strings" + + "code.nonshy.com/nonshy/website/pkg/models" + "code.nonshy.com/nonshy/website/pkg/session" + "code.nonshy.com/nonshy/website/pkg/templates" +) + +// Subscription endpoint - to opt in or out of comment thread subscriptions. +func Subscription() http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Query params. + var ( + tableName = r.FormValue("table_name") + tableID uint64 + subscribe = r.FormValue("subscribe") == "true" + fromURL = r.FormValue("next") // what page to send back to + ) + + // Parse the table ID param. + if idStr := r.FormValue("table_id"); idStr == "" { + session.FlashError(w, r, "Comment table ID required.") + 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) + } + } + + // Redirect URL must be relative. + if !strings.HasPrefix(fromURL, "/") { + // Maybe it's URL encoded? + fromURL, _ = url.QueryUnescape(fromURL) + if !strings.HasPrefix(fromURL, "/") { + fromURL = "/" + } + } + + // Validate everything else. + if _, ok := models.CommentableTables[tableName]; !ok { + session.FlashError(w, r, "You can not comment on that.") + templates.Redirect(w, "/") + 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 their subscription. + sub, err := models.GetSubscription(currentUser, tableName, tableID) + if err != nil { + // If they want to subscribe, insert their row. + if subscribe { + 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.") + } + } + } else { + // Toggle it. + sub.Subscribed = subscribe + if err := sub.Save(); err != nil { + 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.") + } else { + session.Flash(w, r, "You will no longer be notified about new comments on this page.") + } + } + } + + templates.Redirect(w, fromURL) + }) +} diff --git a/pkg/controller/forum/new_post.go b/pkg/controller/forum/new_post.go index 67ce6f6..d34cbfd 100644 --- a/pkg/controller/forum/new_post.go +++ b/pkg/controller/forum/new_post.go @@ -5,6 +5,7 @@ import ( "net/http" "strconv" + "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/markdown" "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/session" @@ -160,6 +161,31 @@ func NewPost() http.HandlerFunc { session.FlashError(w, r, "Couldn't add reply to thread: %s", err) } else { session.Flash(w, r, "Reply added to the thread!") + + // Notify watchers about this new post. + for _, userID := range models.GetSubscribers("threads", thread.ID) { + if userID == currentUser.ID { + continue + } + + notif := &models.Notification{ + UserID: userID, + AboutUser: *currentUser, + Type: models.NotificationAlsoPosted, + TableName: "threads", + TableID: thread.ID, + Message: message, + Link: fmt.Sprintf("/forum/thread/%d", thread.ID), + } + if err := models.CreateNotification(notif); err != nil { + log.Error("Couldn't create thread reply notification for subscriber %d: %s", userID, err) + } + } + + // Subscribe the current user to further responses on this thread. + if _, err := models.SubscribeTo(currentUser, "threads", thread.ID); err != nil { + log.Error("Couldn't subscribe user %d to forum thread %d: %s", currentUser.ID, thread.ID, err) + } } templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID)) return @@ -178,6 +204,12 @@ func NewPost() http.HandlerFunc { session.FlashError(w, r, "Couldn't create thread: %s", err) } else { session.Flash(w, r, "Thread created!") + + // Subscribe the current user to responses on this thread. + if _, err := models.SubscribeTo(currentUser, "threads", thread.ID); err != nil { + log.Error("Couldn't subscribe user %d to forum thread %d: %s", currentUser.ID, thread.ID, err) + } + templates.Redirect(w, fmt.Sprintf("/forum/thread/%d", thread.ID)) return } diff --git a/pkg/controller/forum/thread.go b/pkg/controller/forum/thread.go index e153e6f..e8379a9 100644 --- a/pkg/controller/forum/thread.go +++ b/pkg/controller/forum/thread.go @@ -74,11 +74,15 @@ func Thread() http.HandlerFunc { return } + // Is the current user subscribed to notifications on this thread? + _, isSubscribed := models.IsSubscribed(currentUser, "threads", thread.ID) + var vars = map[string]interface{}{ - "Forum": forum, - "Thread": thread, - "Comments": comments, - "Pager": pager, + "Forum": forum, + "Thread": thread, + "Comments": comments, + "Pager": pager, + "IsSubscribed": isSubscribed, } if err := tmpl.Execute(w, r, vars); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/pkg/controller/photo/view.go b/pkg/controller/photo/view.go index 69c03cb..fce7aa5 100644 --- a/pkg/controller/photo/view.go +++ b/pkg/controller/photo/view.go @@ -76,13 +76,17 @@ func View() http.HandlerFunc { log.Error("Couldn't list comments for photo %d: %s", photo.ID, err) } + // Is the current user subscribed to notifications on this thread? + _, isSubscribed := models.IsSubscribed(currentUser, "photos", photo.ID) + var vars = map[string]interface{}{ - "IsOwnPhoto": currentUser.ID == user.ID, - "User": user, - "Photo": photo, - "LikeMap": likeMap, - "CommentMap": commentMap, - "Comments": comments, + "IsOwnPhoto": currentUser.ID == user.ID, + "User": user, + "Photo": photo, + "LikeMap": likeMap, + "CommentMap": commentMap, + "Comments": comments, + "IsSubscribed": isSubscribed, } if err := tmpl.Execute(w, r, vars); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/pkg/models/comment.go b/pkg/models/comment.go index 7f726f4..756484f 100644 --- a/pkg/models/comment.go +++ b/pkg/models/comment.go @@ -22,7 +22,8 @@ type Comment struct { // CommentableTables are the set of table names that allow comments (via the // generic "/comments" URI which accepts a table_name param) var CommentableTables = map[string]interface{}{ - "photos": nil, + "photos": nil, + "threads": nil, } // Preload related tables for the forum (classmethod). diff --git a/pkg/models/deletion/delete_user.go b/pkg/models/deletion/delete_user.go index 5eea7c5..edc69ad 100644 --- a/pkg/models/deletion/delete_user.go +++ b/pkg/models/deletion/delete_user.go @@ -23,6 +23,7 @@ func DeleteUser(user *models.User) error { {"Likes", DeleteLikes}, {"Threads", DeleteForumThreads}, {"Comments", DeleteComments}, + {"Subscriptions", DeleteSubscriptions}, {"Photos", DeleteUserPhotos}, {"Certification Photo", DeleteCertification}, {"Messages", DeleteUserMessages}, @@ -126,6 +127,16 @@ func DeleteNotifications(userID uint64) error { return result.Error } +// DeleteSubscriptions scrubs all notification subscriptions about a user. +func DeleteSubscriptions(userID uint64) error { + log.Error("DeleteUser: DeleteSubscriptions(%d)", userID) + result := models.DB.Where( + "user_id = ?", + userID, + ).Delete(&models.Subscription{}) + return result.Error +} + // DeleteLikes scrubs all Likes about a user. func DeleteLikes(userID uint64) error { log.Error("DeleteUser: DeleteLikes(%d)", userID) diff --git a/pkg/models/models.go b/pkg/models/models.go index 651547c..b41447b 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -21,4 +21,5 @@ func AutoMigrate() { DB.AutoMigrate(&Comment{}) DB.AutoMigrate(&Like{}) DB.AutoMigrate(&Notification{}) + DB.AutoMigrate(&Subscription{}) } diff --git a/pkg/models/notification.go b/pkg/models/notification.go index dde2484..2e9fd7a 100644 --- a/pkg/models/notification.go +++ b/pkg/models/notification.go @@ -34,6 +34,8 @@ const ( NotificationLike NotificationType = "like" NotificationFriendApproved = "friendship_approved" NotificationComment = "comment" + NotificationAlsoCommented = "also_comment" + NotificationAlsoPosted = "also_posted" // forum replies NotificationCertRejected = "cert_rejected" NotificationCertApproved = "cert_approved" NotificationCustom = "custom" // custom message pushed @@ -104,8 +106,10 @@ func (n *Notification) Save() error { // NotificationBody can store remote tables mapped. type NotificationBody struct { - PhotoID uint64 - Photo *Photo + PhotoID uint64 + ThreadID uint64 + Photo *Photo + Thread *Thread } type NotificationMap map[uint64]*NotificationBody @@ -131,6 +135,14 @@ func MapNotifications(ns []*Notification) NotificationMap { result[row.ID] = &NotificationBody{} } + result.mapNotificationPhotos(IDs) + result.mapNotificationThreads(IDs) + + return result +} + +// Helper function of MapNotifications to eager load Photo attachments. +func (nm NotificationMap) mapNotificationPhotos(IDs []uint64) { type scanner struct { PhotoID uint64 NotificationID uint64 @@ -157,7 +169,7 @@ func MapNotifications(ns []*Notification) NotificationMap { var photoIDs = []uint64{} for _, row := range scan { // Store the photo ID in the result now. - result[row.NotificationID].PhotoID = row.PhotoID + nm[row.NotificationID].PhotoID = row.PhotoID photoIDs = append(photoIDs, row.PhotoID) } @@ -167,13 +179,58 @@ func MapNotifications(ns []*Notification) NotificationMap { log.Error("Couldn't load photo IDs for notifications: %s", err) } else { // Marry them to their notification IDs. - for _, body := range result { + for _, body := range nm { if photo, ok := photos[body.PhotoID]; ok { body.Photo = photo } } } } - - return result +} + +// Helper function of MapNotifications to eager load Thread attachments. +func (nm NotificationMap) mapNotificationThreads(IDs []uint64) { + type scanner struct { + ThreadID uint64 + NotificationID uint64 + } + var scan []scanner + + // Load all of these that have threads. + err := DB.Table( + "notifications", + ).Joins( + "JOIN threads ON (notifications.table_name='threads' AND notifications.table_id=threads.id)", + ).Select( + "threads.id AS thread_id", + "notifications.id AS notification_id", + ).Where( + "notifications.id IN ?", + IDs, + ).Scan(&scan) + if err.Error != nil { + log.Error("Couldn't select thread IDs for notifications: %s", err.Error) + } + + // Collect and load all the threads by ID. + var threadIDs = []uint64{} + for _, row := range scan { + // Store the thread ID in the result now. + nm[row.NotificationID].ThreadID = row.ThreadID + threadIDs = append(threadIDs, row.ThreadID) + } + + // Load the threads. + if len(threadIDs) > 0 { + if threads, err := GetThreads(threadIDs); err != nil { + log.Error("Couldn't load thread IDs for notifications: %s", err) + } else { + // Marry them to their notification IDs. + for _, body := range nm { + if thread, ok := threads[body.ThreadID]; ok { + body.Thread = thread + } + } + } + } } diff --git a/pkg/models/subscription.go b/pkg/models/subscription.go new file mode 100644 index 0000000..5609548 --- /dev/null +++ b/pkg/models/subscription.go @@ -0,0 +1,81 @@ +package models + +import ( + "time" + + "code.nonshy.com/nonshy/website/pkg/log" +) + +// Subscription table - for notifications. You comment on someone's post, you get subscribed +// to other comments added to the post (unless you opt off). +type Subscription struct { + ID uint64 `gorm:"primaryKey"` + UserID uint64 `gorm:"index"` // who it belongs to + Subscribed bool `gorm:"index"` + TableName string // on which of your tables (photos, comments, ...) + TableID uint64 + CreatedAt time.Time + UpdatedAt time.Time +} + +// GetSubscription looks for an existing subscription or returns error if not found. +func GetSubscription(user *User, tableName string, tableID uint64) (*Subscription, error) { + var s *Subscription + result := DB.Model(s).Where( + "user_id = ? AND table_name = ? AND table_id = ?", + user.ID, tableName, tableID, + ).First(&s) + return s, result.Error +} + +// GetSubscribers returns all of the UserIDs that are subscribed to a thread. +func GetSubscribers(tableName string, tableID uint64) []uint64 { + var userIDs = []uint64{} + result := DB.Table( + "subscriptions", + ).Select( + "user_id", + ).Where( + "table_name = ? AND table_id = ? AND subscribed IS TRUE", + tableName, tableID, + ).Scan(&userIDs) + + if result.Error != nil { + log.Error("GetSubscribers(%s, %d): couldn't get user IDs: %s", tableName, tableID) + } + + return userIDs +} + +// IsSubscribed checks whether a user is currently subscribed (and notified) to a thing. +// Returns whether the row exists, and whether the user is to be notified (false if opted out). +func IsSubscribed(user *User, tableName string, tableID uint64) (exists bool, notified bool) { + if sub, err := GetSubscription(user, tableName, tableID); err != nil { + return false, false + } else { + return true, sub.Subscribed + } +} + +// SubscribeTo creates a subscription to a thing (comment thread) to be notified of future activity on. +func SubscribeTo(user *User, tableName string, tableID uint64) (*Subscription, error) { + // Is there already a subscription row? + if sub, err := GetSubscription(user, tableName, tableID); err == nil { + return sub, err + } + + // Create the default subscription. + sub := &Subscription{ + UserID: user.ID, + Subscribed: true, + TableName: tableName, + TableID: tableID, + } + result := DB.Create(sub) + return sub, result.Error +} + +// Save a subscription. +func (n *Subscription) Save() error { + return DB.Save(n).Error +} diff --git a/pkg/router/router.go b/pkg/router/router.go index 03eb196..f41dd70 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -54,6 +54,7 @@ func New() http.Handler { mux.Handle("/users/block", middleware.LoginRequired(block.BlockUser())) mux.Handle("/users/blocked", middleware.LoginRequired(block.Blocked())) mux.Handle("/comments", middleware.LoginRequired(comment.PostComment())) + mux.Handle("/comments/subscription", middleware.LoginRequired(comment.Subscription())) mux.Handle("/admin/unimpersonate", middleware.LoginRequired(admin.Unimpersonate())) // Certification Required. Pages that only full (verified) members can access. diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index be1526a..ce9ca94 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -218,6 +218,26 @@ {{end}} + {{else if eq .Type "also_comment"}} + + + {{.AboutUser.Username}} + also commented on a + + {{if eq .TableName "photos"}} + photo + {{else}} + {{.TableName}} + {{end}} + + that you replied to: + + {{else if eq .Type "also_posted"}} + + + {{.AboutUser.Username}} + replied to a forum thread that you follow: + {{else if eq .Type "friendship_approved"}} @@ -242,7 +262,14 @@ {{if .Message}}
- {{ToMarkdown .Message}} +
{{ToMarkdown (TrimEllipses .Message 256)}}
+
+ {{end}} + + + {{if $Body.Thread}} +
+ On thread: {{$Body.Thread.Title}}
{{end}} diff --git a/web/templates/forum/thread.html b/web/templates/forum/thread.html index 7c3cfdb..c287006 100644 --- a/web/templates/forum/thread.html +++ b/web/templates/forum/thread.html @@ -71,11 +71,24 @@ Updated {{SincePrettyCoarse .Thread.UpdatedAt}} ago -

+

Found {{.Pager.Total}} post{{Pluralize64 .Pager.Total}} on this thread (page {{.Pager.Page}} of {{.Pager.Pages}}).

+

+ + + + {{if .IsSubscribed}} + Disable notifications about this thread + {{else}} + Enable notifications about this thread + {{end}} + + +

+