diff --git a/pkg/config/page_sizes.go b/pkg/config/page_sizes.go index 9601f6b..536eeac 100644 --- a/pkg/config/page_sizes.go +++ b/pkg/config/page_sizes.go @@ -15,6 +15,8 @@ var ( PageSizePrivatePhotoGrantees = 12 PageSizeAdminCertification = 20 PageSizeAdminFeedback = 20 + PageSizeAdminFeedbackNotesPage = 5 // feedback on User Notes page + PageSizeAdminUserNotes = 10 // other users' notes PageSizeSiteGallery = 16 PageSizeUserGallery = 16 PageSizeInboxList = 20 // sidebar list diff --git a/pkg/controller/account/profile.go b/pkg/controller/account/profile.go index 504adfe..ff8d607 100644 --- a/pkg/controller/account/profile.go +++ b/pkg/controller/account/profile.go @@ -119,6 +119,7 @@ func Profile() http.HandlerFunc { "IsFriend": isFriend, "IsPrivate": isPrivate, "PhotoCount": models.CountPhotosICanSee(user, currentUser), + "NoteCount": models.CountNotesAboutUser(currentUser, user), "OnChat": worker.GetChatStatistics().IsOnline(user.Username), // Details on who likes the photo. diff --git a/pkg/controller/account/user_note.go b/pkg/controller/account/user_note.go new file mode 100644 index 0000000..f2c3e5f --- /dev/null +++ b/pkg/controller/account/user_note.go @@ -0,0 +1,160 @@ +package account + +import ( + "net/http" + "net/url" + "regexp" + + "code.nonshy.com/nonshy/website/pkg/config" + "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" +) + +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] + } + + // 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="+url.QueryEscape(r.URL.String())) + return + } + + // Is the site under a Maintenance Mode restriction? + if middleware.MaintenanceMode(currentUser, w, r) { + return + } + + // Banned or disabled? Only admin can view then. + if user.Status != models.UserStatusActive && !currentUser.IsAdmin { + templates.NotFoundPage(w, r) + return + } + + // Is either one blocking? + if models.IsBlocking(currentUser.ID, user.ID) && !currentUser.IsAdmin { + templates.NotFoundPage(w, r) + return + } + + // Look up our current note about this person. + var myNote = models.GetNoteBetweenUsers(currentUser, user) + + // Are we submitting a note? + if r.Method == http.MethodPost { + var message = r.FormValue("message") + + // Update & save our note. + if message == "" { + // Delete it. + if err := myNote.Delete(); err != nil && err != models.ErrNoUserNoteToDelete { + session.FlashError(w, r, "Error deleting your note: %s", err) + } else if err == nil { + session.Flash(w, r, "Your note was deleted!") + } + } else { + // Update it. + myNote.Message = message + if err := myNote.Save(); err != nil { + session.FlashError(w, r, "Error saving your note: %s", err) + } else { + session.Flash(w, r, "Your notes have been saved!") + } + } + + templates.Redirect(w, r.URL.Path) + return + } + + // Admin view: paginate their feedback & reports. + var ( + feedback = []*models.Feedback{} + otherNotes = []*models.UserNote{} + userMap = models.UserMap{} + notePager = &models.Pagination{ + Page: 1, + PerPage: config.PageSizeAdminUserNotes, + Sort: "updated_at desc", + } + fbPager = &models.Pagination{ + Page: 1, + PerPage: config.PageSizeAdminFeedbackNotesPage, + Sort: "created_at desc", + } + ) + notePager.ParsePage(r) + fbPager.ParsePage(r) + if currentUser.IsAdmin { + // Paginate all notes written about this user. + if on, err := models.PaginateUserNotes(user, notePager); err != nil { + session.FlashError(w, r, "Paginating user notes: %s", err) + } else { + otherNotes = on + } + + // Paginate feedback & reports. + if fb, err := models.PaginateFeedbackAboutUser(user, fbPager); err != nil { + session.FlashError(w, r, "Paginating feedback on this user: %s", err) + } else { + feedback = fb + } + + // Map user IDs for the Feedback Reply-To line and the Note Givers for the paginated notes. + var userIDs = []uint64{} + for _, p := range feedback { + if p.UserID > 0 { + userIDs = append(userIDs, p.UserID) + } + } + for _, p := range otherNotes { + if p.UserID > 0 { + userIDs = append(userIDs, p.UserID) + } + } + if um, err := models.MapUsers(currentUser, userIDs); err != nil { + session.FlashError(w, r, "Couldn't map user IDs: %s", err) + } else { + userMap = um + } + } + + vars := map[string]interface{}{ + "User": user, + "PhotoCount": models.CountPhotosICanSee(user, currentUser), + "NoteCount": models.CountNotesAboutUser(currentUser, user), + "MyNote": myNote, + + // Admin concerns. + "Feedback": feedback, + "FeedbackPager": fbPager, + "OtherNotes": otherNotes, + "NotePager": notePager, + "UserMap": userMap, + } + + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/chat/chat.go b/pkg/controller/chat/chat.go index 9082e32..555396c 100644 --- a/pkg/controller/chat/chat.go +++ b/pkg/controller/chat/chat.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "sort" "strings" "time" @@ -154,7 +155,7 @@ func Landing() http.HandlerFunc { "IsShyUser": isShy, // Pre-populate the "who's online" widget from backend cache data - "ChatStatistics": worker.GetChatStatistics(), + "ChatStatistics": FilteredChatStatistics(currentUser), } if err := tmpl.Execute(w, r, vars); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -163,6 +164,35 @@ func Landing() http.HandlerFunc { }) } +// FilteredChatStatistics will return a copy of the cached ChatStatistics but where the Usernames list is +// filtered down (and the online user counts, accordingly) by blocklists. +func FilteredChatStatistics(currentUser *models.User) worker.ChatStatistics { + var stats = worker.GetChatStatistics() + var result = worker.ChatStatistics{ + UserCount: stats.UserCount, + Usernames: []string{}, + Cameras: stats.Cameras, + } + + // Who are we blocking? + var blockedUsernames = map[string]interface{}{} + for _, username := range models.BlockedUsernames(currentUser.ID) { + blockedUsernames[username] = nil + } + + // Filter the online users listing. + for _, username := range stats.Usernames { + if _, ok := blockedUsernames[username]; !ok { + result.Usernames = append(result.Usernames, username) + } + } + + // Sort the names. + sort.Strings(result.Usernames) + + return result +} + // SendBlocklist syncs the user blocklist to the chat server prior to sending them over. func SendBlocklist(user *models.User) error { // Get the user's blocklist. diff --git a/pkg/controller/photo/user_gallery.go b/pkg/controller/photo/user_gallery.go index 4f60902..0821b7a 100644 --- a/pkg/controller/photo/user_gallery.go +++ b/pkg/controller/photo/user_gallery.go @@ -148,6 +148,7 @@ func UserPhotos() http.HandlerFunc { "User": user, "Photos": photos, "PhotoCount": models.CountPhotosICanSee(user, currentUser), + "NoteCount": models.CountNotesAboutUser(currentUser, user), "PublicPhotoCount": models.CountPublicPhotos(user.ID), "InnerCircleMinimumPublicPhotos": config.InnerCircleMinimumPublicPhotos, "Pager": pager, diff --git a/pkg/models/deletion/delete_user.go b/pkg/models/deletion/delete_user.go index f673211..1d2747b 100644 --- a/pkg/models/deletion/delete_user.go +++ b/pkg/models/deletion/delete_user.go @@ -35,6 +35,7 @@ func DeleteUser(user *models.User) error { {"Messages", DeleteUserMessages}, {"Friends", DeleteFriends}, {"Profile Fields", DeleteProfile}, + {"User Notes", DeleteUserNotes}, } for _, item := range todo { if err := item.Fn(user.ID); err != nil { @@ -280,3 +281,13 @@ func DeleteComments(userID uint64) error { ).Delete(&models.Comment{}) return result.Error } + +// DeleteUserNotes scrubs data for deleting a user. +func DeleteUserNotes(userID uint64) error { + log.Error("DeleteUser: DeleteUserNotes(%d)", userID) + result := models.DB.Where( + "user_id = ? OR about_user_id = ?", + userID, userID, + ).Delete(&models.UserNote{}) + return result.Error +} diff --git a/pkg/models/feedback.go b/pkg/models/feedback.go index 00390d1..94a001d 100644 --- a/pkg/models/feedback.go +++ b/pkg/models/feedback.go @@ -76,6 +76,41 @@ func PaginateFeedback(acknowledged bool, intent string, pager *Pagination) ([]*F return fb, result.Error } +// PaginateFeedbackAboutUser digs through feedback about a specific user ID or one of their Photos. +// +// 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) { + var ( + fb = []*Feedback{} + photoIDs, _ = user.AllPhotoIDs() + wheres = []string{} + placeholders = []interface{}{} + ) + + wheres = append(wheres, ` + (table_name = 'users' AND table_id = ?) OR + (table_name = 'photos' AND table_id IN ?) + `) + placeholders = append(placeholders, user.ID, photoIDs) + + query := DB.Where( + strings.Join(wheres, " AND "), + placeholders..., + ).Order( + pager.Sort, + ) + + query.Model(&Feedback{}).Count(&pager.Total) + + result := query.Offset( + pager.GetOffset(), + ).Limit(pager.PerPage).Find(&fb) + + return fb, result.Error +} + // CreateFeedback saves a new Feedback row to the DB. func CreateFeedback(fb *Feedback) error { result := DB.Create(fb) diff --git a/pkg/models/models.go b/pkg/models/models.go index 90eea4a..953fb5c 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -29,4 +29,5 @@ func AutoMigrate() { DB.AutoMigrate(&AdminGroup{}) DB.AutoMigrate(&AdminScope{}) DB.AutoMigrate(&UserLocation{}) + DB.AutoMigrate(&UserNote{}) } diff --git a/pkg/models/private_photo.go b/pkg/models/private_photo.go index e3d9865..d8ad346 100644 --- a/pkg/models/private_photo.go +++ b/pkg/models/private_photo.go @@ -100,6 +100,25 @@ func (u *User) AllPrivatePhotoIDs() ([]uint64, error) { return photoIDs, nil } +// AllPhotoIDs returns the listing of all IDs of the user's photos. +func (u *User) AllPhotoIDs() ([]uint64, error) { + var photoIDs = []uint64{} + err := DB.Table( + "photos", + ).Select( + "photos.id AS id", + ).Where( + "user_id = ?", + u.ID, + ).Scan(&photoIDs) + + if err.Error != nil { + return photoIDs, fmt.Errorf("AllPhotoIDs(%s): %s", u.Username, err.Error) + } + + return photoIDs, nil +} + // IsPrivateUnlocked quickly sees if sourceUserID has unlocked private photos for targetUserID to see. func IsPrivateUnlocked(sourceUserID, targetUserID uint64) bool { pb := &PrivatePhoto{} diff --git a/pkg/models/user_note.go b/pkg/models/user_note.go new file mode 100644 index 0000000..c4040bc --- /dev/null +++ b/pkg/models/user_note.go @@ -0,0 +1,112 @@ +package models + +import ( + "errors" + "strings" + "time" + + "code.nonshy.com/nonshy/website/pkg/log" +) + +// UserNote table where users can write private notes about each other. +type UserNote struct { + ID uint64 `gorm:"primaryKey"` + UserID uint64 `gorm:"index"` // author ID + AboutUserID uint64 `gorm:"index"` // target user ID + Message string + CreatedAt time.Time + UpdatedAt time.Time +} + +// GetNoteBetweenUsers finds the existing note or creates an empty model if not found. +func GetNoteBetweenUsers(currentUser *User, user *User) *UserNote { + var ( + note = &UserNote{} + result = DB.Model(note).Where( + "user_id = ? AND about_user_id = ?", + currentUser.ID, user.ID, + ).First(¬e) + ) + if result.Error != nil { + note = &UserNote{ + UserID: currentUser.ID, + AboutUserID: user.ID, + } + } + return note +} + +// CountNotesAboutUser returns the number of notes (the current user) has about the other user. +// +// For regular user, will return zero or one; for admins, will return the total count of notes +// left by any other users about this user. +func CountNotesAboutUser(currentUser *User, user *User) int64 { + var ( + wheres = []string{} + placeholders = []interface{}{} + count int64 + ) + + if currentUser.IsAdmin { + wheres = append(wheres, "about_user_id = ?") + placeholders = append(placeholders, user.ID) + } else { + wheres = append(wheres, "user_id = ? AND about_user_id = ?") + placeholders = append(placeholders, currentUser.ID, user.ID) + } + + query := DB.Model(&UserNote{}).Where( + strings.Join(wheres, " AND "), + placeholders..., + ).Count(&count) + if query.Error != nil { + log.Error("CountNotesAboutUser(%s, %s): %s", currentUser.Username, user.Username, query.Error) + } + + return count +} + +// PaginateUserNotes shows all notes written by users about the target user. +func PaginateUserNotes(user *User, pager *Pagination) ([]*UserNote, error) { + var ( + notes = []*UserNote{} + wheres = []string{} + placeholders = []interface{}{} + ) + + wheres = append(wheres, "about_user_id = ?") + placeholders = append(placeholders, user.ID) + + query := DB.Joins("JOIN users ON users.id = user_notes.user_id").Where( + strings.Join(wheres, " AND "), + placeholders..., + ).Order( + "users.is_admin desc, " + pager.Sort, + ) + + query.Model(&UserNote{}).Count(&pager.Total) + + result := query.Offset( + pager.GetOffset(), + ).Limit(pager.PerPage).Find(¬es) + + return notes, result.Error +} + +// Save the note. +func (p *UserNote) Save() error { + if p.ID == 0 { + return DB.Create(p).Error + } + return DB.Save(p).Error +} + +var ErrNoUserNoteToDelete = errors.New("you had no user note to delete") + +// Delete the DB entry. +func (p *UserNote) Delete() error { + if p.ID == 0 { + return ErrNoUserNoteToDelete + } + return DB.Delete(p).Error +} diff --git a/pkg/router/router.go b/pkg/router/router.go index 0b89c8f..9109fd1 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -55,6 +55,7 @@ func New() http.Handler { mux.Handle("/photo/certification", middleware.LoginRequired(photo.Certification())) mux.Handle("/photo/private", middleware.LoginRequired(photo.Private())) mux.Handle("/photo/private/share", middleware.LoginRequired(photo.Share())) + mux.Handle("/notes/u/", middleware.LoginRequired(account.UserNotes())) mux.Handle("/messages", middleware.LoginRequired(inbox.Inbox())) mux.Handle("/messages/read/", middleware.LoginRequired(inbox.Inbox())) mux.Handle("/messages/compose", middleware.LoginRequired(inbox.Compose())) diff --git a/pkg/worker/barertc.go b/pkg/worker/barertc.go index 03bb08d..b165502 100644 --- a/pkg/worker/barertc.go +++ b/pkg/worker/barertc.go @@ -14,7 +14,7 @@ import ( // ChatStatistics is the json result of the BareRTC /api/statistics endpoint. type ChatStatistics struct { UserCount int - Usernames []string `json:",omitempty"` + Usernames []string Cameras struct { Blue int Red int @@ -41,14 +41,6 @@ func SetChatStatistics(stats *ChatStatistics) { cachedChatStatistics = stats } -// Privatized returns a copy of ChatStatistics with the usernames list scrubbed. -func (cs ChatStatistics) Privatized() ChatStatistics { - return ChatStatistics{ - UserCount: cs.UserCount, - Cameras: cs.Cameras, - } -} - // IsOnline returns whether the username is currently logged-in to chat. func (cs ChatStatistics) IsOnline(username string) bool { for _, user := range cs.Usernames { diff --git a/web/templates/account/profile.html b/web/templates/account/profile.html index 152ae03..6d472ec 100644 --- a/web/templates/account/profile.html +++ b/web/templates/account/profile.html @@ -284,6 +284,17 @@ +
  • + + + + + + Notes + {{if .NoteCount}}{{.NoteCount}}{{end}} + + +
  • diff --git a/web/templates/account/user_notes.html b/web/templates/account/user_notes.html new file mode 100644 index 0000000..7533c10 --- /dev/null +++ b/web/templates/account/user_notes.html @@ -0,0 +1,270 @@ +{{define "title"}} + Notes about {{.User.Username}} +{{end}} +{{define "content"}} +
    +
    +
    +
    +
    +
    +

    + + {{template "title" .}} +

    +
    +
    +
    +
    +
    + + + {{$Root := .}} + +
    + + + +
    +

    + On this page you may jot down some private notes for yourself + about {{.User.Username}}, for example to remember a topic you discussed on chat or + to remember what they said their favorite color was -- it's up to you! +

    + +

    + Your notes will not be visible to {{.User.Username}} but will be visible + to website administrators. +

    +
    + +
    + +
    +
    + {{InputCSRF}} +
    + + +
    + + + {{if not .MyNote.UpdatedAt.IsZero}} +
    + You last updated your notes {{SincePrettyCoarse .MyNote.UpdatedAt}} ago. +
    + {{end}} + +
    + +
    +
    + + + {{if .CurrentUser.IsAdmin}} +
    +
    +

    + + Everyone Else's Notes +

    +
    +
    + {{if .NotePager.Total}} +

    + Found {{.NotePager.Total}} note{{Pluralize64 .NotePager.Total}} about this user (page {{.NotePager.Page}} of {{.NotePager.Pages}}). +

    + {{end}} + +

    + Note: admin notes are shown first, and the rest are ordered by recently updated. +

    + +
    + {{SimplePager .NotePager}} +
    + +
    + + {{range .OtherNotes}} + + {{end}} + +
    + +
    + {{SimplePager .NotePager}} +
    +
    +
    + {{end}} +
    + + + {{if .CurrentUser.IsAdmin}} +
    +
    +
    +

    + Admin Feedback & Reports +

    +
    + +
    + {{if .FeedbackPager.Total}} + + Found {{.FeedbackPager.Total}} report{{Pluralize64 .FeedbackPager.Total}} about this user (page {{.FeedbackPager.Page}} of {{.FeedbackPager.Pages}}). + + {{end}} + +
    + {{SimplePager .FeedbackPager}} +
    + + {{range .Feedback}} + {{$User := $Root.UserMap.Get .UserID}} +
    +
    + + + + + + + + + + + + + + + + + + +
    + Intent: + {{.Intent}}
    + Subject: + {{.Subject}}
    + Table: + + {{if eq .TableName ""}} + n/a + {{if ne .TableID 0}} - {{.TableID}}{{end}} + {{else if eq .TableName "users"}} + Users: {{.TableID}} + + {{else if eq .TableName "photos"}} + Photos: {{.TableID}} + + + {{else if eq .TableName "messages"}} + Messages: {{.TableID}} + + {{else}} + {{.TableName}}: {{.TableID}} + + {{end}} +
    + Reply To: + + {{if $User}} + {{$User.Username}} + {{else if ne .ReplyTo ""}} + {{.ReplyTo}} + {{else}} + n/a + {{end}} +
    + +
    + {{if eq .Message ""}} +

    No message attached.

    + {{else}} + {{ToMarkdown .Message}} + {{end}} +
    + +
    +
    + {{end}} + +
    + {{SimplePager .FeedbackPager}} +
    +
    +
    +
    + {{end}} +
    +
    + +
    +{{end}} diff --git a/web/templates/chat.html b/web/templates/chat.html index f389a50..42c0e7f 100644 --- a/web/templates/chat.html +++ b/web/templates/chat.html @@ -44,6 +44,8 @@