From f6d076f7c2606c703c566ccc5fcfb335b539b54d Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Sat, 13 Aug 2022 15:39:31 -0700 Subject: [PATCH] Certification Photo Workflow * Add "Site Gallery" page showing all public+gallery member photos. * Add "Certification Required" decorator for gallery and other main pages. * Add the Certification Photo workflow: * Users have a checklist on their dashboard to upload a profile pic and post a certification selfie (two requirements) * Admins notified by email when a new certification pic comes in. * Admin can reject (w/ comment) or approve the pic. * Users can re-upload or delete their pic at the cost of losing certification status if they make any such changes. * Users are emailed when their photo is either approved or rejected. * User Preferences: can now save the explicit pref to your account. * Explicit photos on user pages and site gallery are hidden if the current user hasn't opted-in (user can always see their own explicit photos regardless of the setting) * If a user is viewing a member gallery and explicit pics are hidden, a count of the number of explicit pics is shown to inform the user that more DO exist, they just don't see them. The site gallery does not do this and simply hides explicit photos. --- pkg/config/variable.go | 9 +- pkg/controller/account/settings.go | 28 +- pkg/controller/admin/dashboard.go | 18 ++ pkg/controller/photo/certification.go | 303 ++++++++++++++++++ pkg/controller/photo/site_gallery.go | 61 ++++ pkg/controller/photo/user_gallery.go | 27 +- pkg/middleware/authentication.go | 46 +++ pkg/models/certification.go | 70 ++++ pkg/models/models.go | 1 + pkg/models/pagination.go | 19 +- pkg/models/photo.go | 60 +++- pkg/models/user.go | 37 +++ pkg/router/router.go | 11 +- pkg/templates/template_funcs.go | 18 ++ web/static/css/theme.css | 8 +- web/static/img/certification-example.jpg | Bin 0 -> 28934 bytes web/templates/account/dashboard.html | 57 +++- web/templates/account/profile.html | 21 +- web/templates/account/settings.html | 53 +-- web/templates/admin/certification.html | 84 +++++ web/templates/admin/dashboard.html | 49 +++ web/templates/base.html | 197 +++++++----- web/templates/email/certification_admin.html | 23 ++ .../email/certification_approved.html | 25 ++ .../email/certification_rejected.html | 32 ++ .../errors/certification_required.html | 76 +++++ web/templates/index.html | 2 +- web/templates/photo/certification.html | 166 ++++++++++ .../photo/{user_photos.html => gallery.html} | 120 ++++++- 29 files changed, 1475 insertions(+), 146 deletions(-) create mode 100644 pkg/controller/admin/dashboard.go create mode 100644 pkg/controller/photo/certification.go create mode 100644 pkg/controller/photo/site_gallery.go create mode 100644 pkg/models/certification.go create mode 100644 web/static/img/certification-example.jpg create mode 100644 web/templates/admin/certification.html create mode 100644 web/templates/admin/dashboard.html create mode 100644 web/templates/email/certification_admin.html create mode 100644 web/templates/email/certification_approved.html create mode 100644 web/templates/email/certification_rejected.html create mode 100644 web/templates/errors/certification_required.html create mode 100644 web/templates/photo/certification.html rename web/templates/photo/{user_photos.html => gallery.html} (58%) diff --git a/pkg/config/variable.go b/pkg/config/variable.go index ebd3d16..844d9cb 100644 --- a/pkg/config/variable.go +++ b/pkg/config/variable.go @@ -15,10 +15,11 @@ var Current = DefaultVariable() // Variable configuration attributes (loaded from settings.json). type Variable struct { - BaseURL string - Mail Mail - Redis Redis - Database Database + BaseURL string + AdminEmail string + Mail Mail + Redis Redis + Database Database } // DefaultVariable returns the default settings.json data. diff --git a/pkg/controller/account/settings.go b/pkg/controller/account/settings.go index 4840e4a..f091b94 100644 --- a/pkg/controller/account/settings.go +++ b/pkg/controller/account/settings.go @@ -19,6 +19,14 @@ func Settings() http.HandlerFunc { "Enum": config.ProfileEnums, } + // Load the current user in case of updates. + user, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get CurrentUser: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + // Are we POSTing? if r.Method == http.MethodPost { intent := r.PostFormValue("intent") @@ -30,14 +38,6 @@ func Settings() http.HandlerFunc { dob = r.PostFormValue("dob") ) - // Load the current user in case of updates. - user, err := session.CurrentUser(r) - if err != nil { - session.FlashError(w, r, "Couldn't get CurrentUser: %s", err) - templates.Redirect(w, r.URL.Path) - return - } - // Set user attributes. user.Name = &displayName if len(dob) > 0 { @@ -71,6 +71,18 @@ func Settings() http.HandlerFunc { } session.Flash(w, r, "Profile settings updated!") + case "preferences": + var ( + explicit = r.PostFormValue("explicit") == "true" + ) + + user.Explicit = explicit + + if err := user.Save(); err != nil { + session.FlashError(w, r, "Failed to save user to database: %s", err) + } + + session.Flash(w, r, "Website preferences updated!") case "settings": fallthrough default: diff --git a/pkg/controller/admin/dashboard.go b/pkg/controller/admin/dashboard.go new file mode 100644 index 0000000..15ab6c5 --- /dev/null +++ b/pkg/controller/admin/dashboard.go @@ -0,0 +1,18 @@ +package admin + +import ( + "net/http" + + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// Admin dashboard or landing page (/admin). +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 { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/photo/certification.go b/pkg/controller/photo/certification.go new file mode 100644 index 0000000..43fb7e7 --- /dev/null +++ b/pkg/controller/photo/certification.go @@ -0,0 +1,303 @@ +package photo + +import ( + "bytes" + "io" + "net/http" + "path/filepath" + "strconv" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/log" + "git.kirsle.net/apps/gosocial/pkg/mail" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/photo" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// CertificationRequiredError handles the error page when a user is denied due to lack of certification. +func CertificationRequiredError() http.HandlerFunc { + tmpl := templates.Must("errors/certification_required.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Unexpected error: could not get currentUser.") + templates.Redirect(w, "/") + return + } + + // Get the current user's cert photo (or create the DB record). + cert, err := models.GetCertificationPhoto(currentUser.ID) + if err != nil { + session.FlashError(w, r, "Unexpected error: could not get or create CertificationPhoto record.") + templates.Redirect(w, "/") + return + } + + var vars = map[string]interface{}{ + "CertificationPhoto": cert, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} + +// Certification photo controller. +func Certification() http.HandlerFunc { + tmpl := templates.Must("photo/certification.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Unexpected error: could not get currentUser.") + templates.Redirect(w, "/") + return + } + + // Get the current user's cert photo (or create the DB record). + cert, err := models.GetCertificationPhoto(currentUser.ID) + if err != nil { + session.FlashError(w, r, "Unexpected error: could not get or create CertificationPhoto record.") + templates.Redirect(w, "/") + return + } + + // Uploading? + if r.Method == http.MethodPost { + // Are they deleting their photo? + if r.PostFormValue("delete") == "true" { + 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) + } + cert.Filename = "" + } + + cert.Status = models.CertificationPhotoNeeded + cert.Save() + + // Removing your photo = not certified again. + currentUser.Certified = false + if err := currentUser.Save(); err != nil { + session.FlashError(w, r, "Error saving your User data: %s", err) + } + + session.Flash(w, r, "Your certification photo has been deleted.") + templates.Redirect(w, r.URL.Path) + return + } + + // Get the uploaded file. + file, header, err := r.FormFile("file") + if err != nil { + session.FlashError(w, r, "Error receiving your file: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + var buf bytes.Buffer + io.Copy(&buf, file) + + filename, _, err := photo.UploadPhoto(photo.UploadConfig{ + User: currentUser, + Extension: filepath.Ext(header.Filename), + Data: buf.Bytes(), + }) + if err != nil { + session.FlashError(w, r, "Error processing your upload: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + // Are they replacing their old 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) + } + } + + // Update their certification photo. + cert.Status = models.CertificationPhotoPending + cert.Filename = filename + cert.AdminComment = "" + if err := cert.Save(); err != nil { + session.FlashError(w, r, "Error saving your CertificationPhoto: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + // Set their approval status back to false. + currentUser.Certified = false + if err := currentUser.Save(); err != nil { + 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) + } + + session.Flash(w, r, "Your certification photo has been uploaded and is now awaiting approval.") + templates.Redirect(w, r.URL.Path) + return + } + + var vars = map[string]interface{}{ + "CertificationPhoto": cert, + } + + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} + +// AdminCertification controller (/admin/photo/certification) +func AdminCertification() http.HandlerFunc { + tmpl := templates.Must("admin/certification.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Making a verdict? + if r.Method == http.MethodPost { + var ( + comment = r.PostFormValue("comment") + verdict = r.PostFormValue("verdict") + ) + + userID, err := strconv.Atoi(r.PostFormValue("user_id")) + if err != nil { + session.FlashError(w, r, "Invalid user_id data type.") + templates.Redirect(w, r.URL.Path) + return + } + + // Look up the user in case we'll toggle their Certified state. + user, err := models.GetUser(uint64(userID)) + if err != nil { + session.FlashError(w, r, "Couldn't get user ID %d: %s", userID, err) + templates.Redirect(w, r.URL.Path) + return + } + + // Look up this photo. + cert, err := models.GetCertificationPhoto(uint64(userID)) + if err != nil { + session.FlashError(w, r, "Couldn't get certification photo.") + templates.Redirect(w, r.URL.Path) + return + } else if cert.Filename == "" { + session.FlashError(w, r, "That photo has no filename anymore??") + templates.Redirect(w, r.URL.Path) + return + } + + switch verdict { + case "reject": + if comment == "" { + session.FlashError(w, r, "An admin comment is required when rejecting a photo.") + } else { + cert.Status = models.CertificationPhotoRejected + cert.AdminComment = comment + if err := cert.Save(); err != nil { + session.FlashError(w, r, "Failed to save CertificationPhoto: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + // Uncertify the user just in case. + user.Certified = false + user.Save() + + // 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) + } + } + + session.Flash(w, r, "Certification photo rejected!") + case "approve": + cert.Status = models.CertificationPhotoApproved + cert.AdminComment = "" + if err := cert.Save(); err != nil { + session.FlashError(w, r, "Failed to save CertificationPhoto: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + // Certify the user! + user.Certified = true + user.Save() + + // 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) + } + + session.Flash(w, r, "Certification photo approved!") + default: + session.FlashError(w, r, "Unsupported verdict option: %s", verdict) + } + templates.Redirect(w, r.URL.Path) + return + } + + // Get the pending photos. + pager := &models.Pagination{ + Page: 1, + PerPage: 20, + Sort: "updated_at desc", + } + pager.ParsePage(r) + photos, err := models.CertificationPhotosNeedingApproval(models.CertificationPhotoPending, pager) + if err != nil { + session.FlashError(w, r, "Couldn't load certification photos from DB: %s", err) + } + + // Map user IDs. + var userIDs = []uint64{} + for _, p := range photos { + userIDs = append(userIDs, p.UserID) + } + userMap, err := models.MapUsers(userIDs) + if err != nil { + session.FlashError(w, r, "Couldn't map user IDs: %s", err) + } + + var vars = map[string]interface{}{ + "Photos": photos, + "UserMap": userMap, + "Pager": pager, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/photo/site_gallery.go b/pkg/controller/photo/site_gallery.go new file mode 100644 index 0000000..5231494 --- /dev/null +++ b/pkg/controller/photo/site_gallery.go @@ -0,0 +1,61 @@ +package photo + +import ( + "net/http" + + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// SiteGallery controller (/photo/gallery) to view all members' public gallery pics. +func SiteGallery() http.HandlerFunc { + tmpl := templates.Must("photo/gallery.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Query params. + var ( + viewStyle = r.FormValue("view") // cards (default), full + ) + if viewStyle != "full" { + viewStyle = "cards" + } + + // Load the current user. + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser") + } + + // Get the page of photos. + pager := &models.Pagination{ + Page: 1, + PerPage: 8, + Sort: "created_at desc", + } + pager.ParsePage(r) + photos, err := models.PaginateGalleryPhotos(currentUser.IsAdmin, currentUser.Explicit, pager) + + // Bulk load the users associated with these photos. + var userIDs = []uint64{} + for _, photo := range photos { + userIDs = append(userIDs, photo.UserID) + } + userMap, err := models.MapUsers(userIDs) + if err != nil { + session.FlashError(w, r, "Failed to MapUsers: %s", err) + } + + var vars = map[string]interface{}{ + "IsSiteGallery": true, + "Photos": photos, + "UserMap": userMap, + "Pager": pager, + "ViewStyle": viewStyle, + } + + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/photo/user_gallery.go b/pkg/controller/photo/user_gallery.go index fb2d5b5..b99f458 100644 --- a/pkg/controller/photo/user_gallery.go +++ b/pkg/controller/photo/user_gallery.go @@ -14,7 +14,7 @@ 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/user_photos.html") + tmpl := templates.Must("photo/gallery.html") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Query params. var ( @@ -51,6 +51,12 @@ func UserPhotos() http.HandlerFunc { visibility = append(visibility, models.PhotoFriends, models.PhotoPrivate) } + // Explicit photo filter? + explicit := currentUser.Explicit + if isOwnPhotos { + explicit = true + } + // Get the page of photos. pager := &models.Pagination{ Page: 1, @@ -59,14 +65,21 @@ func UserPhotos() http.HandlerFunc { } pager.ParsePage(r) log.Error("Pager: %+v", pager) - photos, err := models.PaginateUserPhotos(user.ID, visibility, pager) + photos, err := models.PaginateUserPhotos(user.ID, visibility, explicit, pager) + + // Get the count of explicit photos if we are not viewing explicit photos. + var explicitCount int64 + if !explicit { + explicitCount, _ = models.CountExplicitPhotos(user.ID, visibility) + } var vars = map[string]interface{}{ - "IsOwnPhotos": currentUser.ID == user.ID, - "User": user, - "Photos": photos, - "Pager": pager, - "ViewStyle": viewStyle, + "IsOwnPhotos": currentUser.ID == user.ID, + "User": user, + "Photos": photos, + "Pager": pager, + "ViewStyle": viewStyle, + "ExplicitCount": explicitCount, } if err := tmpl.Execute(w, r, vars); err != nil { diff --git a/pkg/middleware/authentication.go b/pkg/middleware/authentication.go index fbc9b56..8adfa3c 100644 --- a/pkg/middleware/authentication.go +++ b/pkg/middleware/authentication.go @@ -3,6 +3,7 @@ package middleware import ( "net/http" + "git.kirsle.net/apps/gosocial/pkg/controller/photo" "git.kirsle.net/apps/gosocial/pkg/log" "git.kirsle.net/apps/gosocial/pkg/session" "git.kirsle.net/apps/gosocial/pkg/templates" @@ -23,3 +24,48 @@ func LoginRequired(handler http.Handler) http.Handler { handler.ServeHTTP(w, r) }) } + +// AdminRequired middleware. +func AdminRequired(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + // User must be logged in. + if currentUser, err := session.CurrentUser(r); err != nil { + log.Error("AdminRequired: %s", err) + errhandler := templates.MakeErrorPage("Login Required", "You must be signed in to view this page.", http.StatusForbidden) + errhandler.ServeHTTP(w, r) + return + } else if !currentUser.IsAdmin { + log.Error("AdminRequired: %s", err) + errhandler := templates.MakeErrorPage("Admin Required", "You do not have permission for this page.", http.StatusForbidden) + errhandler.ServeHTTP(w, r) + return + } + + handler.ServeHTTP(w, r) + }) +} + +// CertRequired middleware: like LoginRequired but user must also have their verification pic certified. +func CertRequired(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + // User must be logged in. + currentUser, err := session.CurrentUser(r) + if err != nil { + log.Error("LoginRequired: %s", err) + errhandler := templates.MakeErrorPage("Login Required", "You must be signed in to view this page.", http.StatusForbidden) + errhandler.ServeHTTP(w, r) + return + } + + // User must be certified. + if !currentUser.Certified || currentUser.ProfilePhoto.ID == 0 { + log.Error("CertRequired: user is not certified") + photo.CertificationRequiredError().ServeHTTP(w, r) + return + } + + handler.ServeHTTP(w, r) + }) +} diff --git a/pkg/models/certification.go b/pkg/models/certification.go new file mode 100644 index 0000000..21f2182 --- /dev/null +++ b/pkg/models/certification.go @@ -0,0 +1,70 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// CertificationPhoto table. +type CertificationPhoto struct { + ID uint64 `gorm:"primaryKey"` + UserID uint64 `gorm:"uniqueIndex"` + Filename string + Filesize int64 + Status CertificationPhotoStatus + AdminComment string + CreatedAt time.Time + UpdatedAt time.Time +} + +type CertificationPhotoStatus string + +const ( + CertificationPhotoNeeded CertificationPhotoStatus = "needed" + CertificationPhotoPending = "pending" + CertificationPhotoApproved = "approved" + CertificationPhotoRejected = "rejected" +) + +// GetCertificationPhoto retrieves the user's record from the DB or upserts their initial record. +func GetCertificationPhoto(userID uint64) (*CertificationPhoto, error) { + p := &CertificationPhoto{} + result := DB.Where("user_id = ?", userID).First(&p) + if result.Error == gorm.ErrRecordNotFound { + p = &CertificationPhoto{ + UserID: userID, + Status: CertificationPhotoNeeded, + } + result = DB.Create(p) + return p, result.Error + } + return p, result.Error +} + +// CertificationPhotosNeedingApproval returns a pager of the pictures that require admin approval. +func CertificationPhotosNeedingApproval(status CertificationPhotoStatus, pager *Pagination) ([]*CertificationPhoto, error) { + var p = []*CertificationPhoto{} + + query := DB.Where( + "status = ?", + status, + ).Order( + pager.Sort, + ) + + // Get the total count. + query.Model(&CertificationPhoto{}).Count(&pager.Total) + + result := query.Offset( + pager.GetOffset(), + ).Limit(pager.PerPage).Find(&p) + + return p, result.Error +} + +// Save photo. +func (p *CertificationPhoto) Save() error { + result := DB.Save(p) + return result.Error +} diff --git a/pkg/models/models.go b/pkg/models/models.go index b80e327..e9d1332 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -11,4 +11,5 @@ func AutoMigrate() { DB.AutoMigrate(&User{}) DB.AutoMigrate(&ProfileField{}) DB.AutoMigrate(&Photo{}) + DB.AutoMigrate(&CertificationPhoto{}) } diff --git a/pkg/models/pagination.go b/pkg/models/pagination.go index c8b77b4..b908c1a 100644 --- a/pkg/models/pagination.go +++ b/pkg/models/pagination.go @@ -1,6 +1,7 @@ package models import ( + "math" "net/http" "strconv" @@ -11,7 +12,6 @@ import ( type Pagination struct { Page int PerPage int - Pages int Total int64 Sort string } @@ -39,8 +39,11 @@ func (p *Pagination) ParsePage(r *http.Request) { // Iter the pages, for templates. func (p *Pagination) Iter() []Page { - var pages = []Page{} - for i := 1; i <= p.Pages; i++ { + var ( + pages = []Page{} + total = p.Pages() + ) + for i := 1; i <= total; i++ { pages = append(pages, Page{ Page: i, IsCurrent: i == p.Page, @@ -49,12 +52,16 @@ func (p *Pagination) Iter() []Page { return pages } +func (p *Pagination) Pages() int { + return int(math.Ceil(float64(p.Total) / float64(p.PerPage))) +} + func (p *Pagination) GetOffset() int { return (p.Page - 1) * p.PerPage } func (p *Pagination) HasNext() bool { - return p.Page < p.Pages + return p.Page < p.Pages() } func (p *Pagination) HasPrevious() bool { @@ -62,8 +69,8 @@ func (p *Pagination) HasPrevious() bool { } func (p *Pagination) Next() int { - if p.Page >= p.Pages { - return p.Pages + if p.Page >= p.Pages() { + return p.Pages() } return p.Page + 1 } diff --git a/pkg/models/photo.go b/pkg/models/photo.go index d08bbc3..0bce017 100644 --- a/pkg/models/photo.go +++ b/pkg/models/photo.go @@ -2,8 +2,9 @@ package models import ( "errors" - "math" "time" + + "gorm.io/gorm" ) // Photo table. @@ -61,20 +62,25 @@ func GetPhoto(id uint64) (*Photo, error) { /* PaginateUserPhotos gets a page of photos belonging to a user ID. */ -func PaginateUserPhotos(userID uint64, visibility []PhotoVisibility, pager *Pagination) ([]*Photo, error) { +func PaginateUserPhotos(userID uint64, visibility []PhotoVisibility, explicitOK bool, pager *Pagination) ([]*Photo, error) { var p = []*Photo{} + var explicit = []bool{false} + if explicitOK { + explicit = []bool{true, false} + } + query := DB.Where( - "user_id = ? AND visibility IN ?", + "user_id = ? AND visibility IN ? AND explicit IN ?", userID, visibility, + explicit, ).Order( pager.Sort, ) // Get the total count. query.Model(&Photo{}).Count(&pager.Total) - pager.Pages = int(math.Ceil(float64(pager.Total) / float64(pager.PerPage))) result := query.Offset( pager.GetOffset(), @@ -83,6 +89,52 @@ func PaginateUserPhotos(userID uint64, visibility []PhotoVisibility, pager *Pagi return p, result.Error } +// CountExplicitPhotos returns the number of explicit photos a user has (so non-explicit viewers can see some do exist) +func CountExplicitPhotos(userID uint64, visibility []PhotoVisibility) (int64, error) { + query := DB.Where( + "user_id = ? AND visibility IN ? AND explicit = ?", + userID, + visibility, + true, + ) + + var count int64 + result := query.Model(&Photo{}).Count(&count) + return count, result.Error +} + +// PaginateGalleryPhotos gets a page of all public user photos for the site gallery. Admin view +// returns ALL photos regardless of Gallery status. +func PaginateGalleryPhotos(adminView bool, explicitOK bool, pager *Pagination) ([]*Photo, error) { + var ( + p = []*Photo{} + query *gorm.DB + ) + + var explicit = []bool{false} + if explicitOK { + explicit = []bool{true, false} + } + + // Admin view: get ALL PHOTOS on the site, period. + if adminView { + query = DB + } else { + query = DB.Where( + "visibility = ? AND gallery = ? AND explicit IN ?", + PhotoPublic, + true, + explicit, + ) + } + + query = query.Order(pager.Sort) + + query.Model(&Photo{}).Count(&pager.Total) + result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&p) + return p, result.Error +} + // Save photo. func (p *Photo) Save() error { result := DB.Save(p) diff --git a/pkg/models/user.go b/pkg/models/user.go index 182e912..d5fac36 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -94,6 +94,43 @@ func FindUser(username string) (*User, error) { return u, result.Error } +// UserMap helps map a set of users to look up by ID. +type UserMap map[uint64]*User + +// MapUsers looks up a set of user IDs in bulk and returns a UserMap suitable for templates. +// Useful to avoid circular reference issues with Photos especially; the Site Gallery queries +// photos of ALL users and MapUsers helps stitch them together for the frontend. +func MapUsers(userIDs []uint64) (UserMap, error) { + var usermap = UserMap{} + + var ( + users = []*User{} + result = (&User{}).Preload().Where("id IN ?", userIDs).Find(&users) + ) + + if result.Error == nil { + for _, row := range users { + usermap[row.ID] = row + } + } + + return usermap, result.Error +} + +// Has a user ID in the map? +func (um UserMap) Has(id uint64) bool { + _, ok := um[id] + return ok +} + +// Get a user from the UserMap. +func (um UserMap) Get(id uint64) *User { + if user, ok := um[id]; ok { + return user + } + return nil +} + // HashPassword sets the user's hashed (bcrypt) password. func (u *User) HashPassword(password string) error { passwd, err := bcrypt.GenerateFromPassword([]byte(password), config.BcryptCost) diff --git a/pkg/router/router.go b/pkg/router/router.go index 6fd0f98..d7ac204 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -6,6 +6,7 @@ import ( "git.kirsle.net/apps/gosocial/pkg/config" "git.kirsle.net/apps/gosocial/pkg/controller/account" + "git.kirsle.net/apps/gosocial/pkg/controller/admin" "git.kirsle.net/apps/gosocial/pkg/controller/api" "git.kirsle.net/apps/gosocial/pkg/controller/index" "git.kirsle.net/apps/gosocial/pkg/controller/photo" @@ -21,7 +22,7 @@ func New() http.Handler { mux.HandleFunc("/logout", account.Logout()) mux.HandleFunc("/signup", account.Signup()) - // Login Required. + // Login Required. Pages that non-certified users can access. mux.Handle("/me", middleware.LoginRequired(account.Dashboard())) mux.Handle("/settings", middleware.LoginRequired(account.Settings())) mux.Handle("/u/", middleware.LoginRequired(account.Profile())) @@ -29,6 +30,14 @@ func New() http.Handler { mux.Handle("/photo/u/", middleware.LoginRequired(photo.UserPhotos())) mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit())) mux.Handle("/photo/delete", middleware.LoginRequired(photo.Delete())) + mux.Handle("/photo/certification", middleware.LoginRequired(photo.Certification())) + + // Certification Required. Pages that only full (verified) members can access. + mux.Handle("/photo/gallery", middleware.CertRequired(photo.SiteGallery())) + + // Admin endpoints. + mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard())) + mux.Handle("/admin/photo/certification", middleware.AdminRequired(photo.AdminCertification())) // JSON API endpoints. mux.HandleFunc("/v1/version", api.Version()) diff --git a/pkg/templates/template_funcs.go b/pkg/templates/template_funcs.go index a823e27..8e7bb6e 100644 --- a/pkg/templates/template_funcs.go +++ b/pkg/templates/template_funcs.go @@ -23,6 +23,24 @@ func TemplateFuncs(r *http.Request) template.FuncMap { "Split": strings.Split, "ToMarkdown": ToMarkdown, "PhotoURL": photo.URLPath, + "Now": time.Now, + "PrettyTitle": func() template.HTML { + return template.HTML(fmt.Sprintf( + `non` + + `shy`, + )) + }, + "Pluralize64": func(count int64, labels ...string) string { + if len(labels) < 2 { + labels = []string{"", "s"} + } + + if count == 1 { + return labels[0] + } else { + return labels[1] + } + }, } } diff --git a/web/static/css/theme.css b/web/static/css/theme.css index cf56a37..fef7769 100644 --- a/web/static/css/theme.css +++ b/web/static/css/theme.css @@ -20,9 +20,15 @@ right: 0; } -/* Photo modals in addition to Bulma .modal */ +/* Photo modals in addition to Bulma .modal-content */ .photo-modal { width: auto !important; max-width: fit-content; max-height: fit-content; +} + +/* Custom bulma tag colors */ +.tag:not(body).is-private.is-light { + color: #CC00CC; + background-color: #FFEEFF; } \ No newline at end of file diff --git a/web/static/img/certification-example.jpg b/web/static/img/certification-example.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a1c2235324eee7b2cb08dbb2693ec48533282e42 GIT binary patch literal 28934 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 az?_vgyd8@DtOsS9kZc6(`~fiiS^7Wu&t6*q literal 0 HcmV?d00001 diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index cddbe48..3b7248a 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -12,7 +12,62 @@
-
+ + {{if or (not .CurrentUser.Certified) (not .CurrentUser.ProfilePhoto.ID)}} +
+
+

+ + Onboarding Checklist +

+
+ +
+

+ You're almost there! Please review the following checklist items to gain + full access to this website. Members are expected to have a face picture + as their default Profile Pic and upload a Verification Photo to become + certified as being a real person. +

+ + +
+
+ {{end}} + +
diff --git a/web/templates/account/profile.html b/web/templates/account/profile.html index 3ff00c2..7cfc21c 100644 --- a/web/templates/account/profile.html +++ b/web/templates/account/profile.html @@ -8,7 +8,7 @@
{{if .User.ProfilePhoto.ID}} - + {{else}} {{end}} @@ -52,20 +52,31 @@
{{if .User.Certified}}
-
+
- Verified! + Certified!
{{else}}
-
+
- Not verified! + Not certified! +
+
+ {{end}} + + {{if .User.IsAdmin}} +
+
+ + + + Admin
{{end}} diff --git a/web/templates/account/settings.html b/web/templates/account/settings.html index 43ad334..3524d15 100644 --- a/web/templates/account/settings.html +++ b/web/templates/account/settings.html @@ -217,30 +217,45 @@
-
-
-

- - Website Preferences -

-
+ +
+ + {{InputCSRF}} -
-
- - -

- Check this box if you are OK seeing explicit content on this site, which may - include erections or sexually charged content. +

+
+

+ + Website Preferences

+
+ +
+
+ + +

+ Check this box if you are OK seeing explicit content on this site, which may + include erections or sexually charged content. +

+
+ +
+ +
-
+ +
{{InputCSRF}} diff --git a/web/templates/admin/certification.html b/web/templates/admin/certification.html new file mode 100644 index 0000000..63a1db0 --- /dev/null +++ b/web/templates/admin/certification.html @@ -0,0 +1,84 @@ +{{define "title"}}Admin - Certification Photos{{end}} +{{define "content"}} +
+
+
+
+

+ Admin / Certification Photos +

+
+
+
+ +
+
+ There {{Pluralize64 .Pager.Total "is" "are"}} {{.Pager.Total}} Certification Photo{{Pluralize64 .Pager.Total}} needing approval. +
+ + {{$Root := .}} +
+ {{range .Photos}} +
+ {{$User := $Root.UserMap.Get .UserID}} + + {{InputCSRF}} + + +
+ +
+
+ +
+
+
+
+
+
+ {{if $User.ProfilePhoto.ID}} + + {{else}} + + {{end}} +
+
+
+

{{or $User.Name "(no name)"}}

+

+ + {{$User.Username}} +

+
+
+ +
+ +
+
+
+ + +
+
+ +
+ {{end}} +
+
+ +
+{{end}} \ No newline at end of file diff --git a/web/templates/admin/dashboard.html b/web/templates/admin/dashboard.html new file mode 100644 index 0000000..ba8158d --- /dev/null +++ b/web/templates/admin/dashboard.html @@ -0,0 +1,49 @@ +{{define "content"}} +
+
+
+
+

Admin Dashboard

+
+
+
+ +
+
+
+
+ + + +
+
+ +
+
+
+

Notifications

+
+ +
+ TBD. +
+
+
+
+
+
+{{end}} \ No newline at end of file diff --git a/web/templates/base.html b/web/templates/base.html index c999c90..7515629 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -11,101 +11,138 @@ {{template "title" .}} - {{ .Title }} -
- +
{{if .Flashes}}
diff --git a/web/templates/email/certification_admin.html b/web/templates/email/certification_admin.html new file mode 100644 index 0000000..d69fae0 --- /dev/null +++ b/web/templates/email/certification_admin.html @@ -0,0 +1,23 @@ +{{define "content"}} + + + + +

New Certification Photo Needs Approval

+ +

+ The user {{.Data.User.Username}} has uploaded a Certification Photo + and it needs admin approval. Click the link below to view pending Certification + Photos: +

+ +

+ {{.Data.URL}} +

+ +

+ This is an automated e-mail; do not reply to this message. +

+ + +{{end}} \ No newline at end of file diff --git a/web/templates/email/certification_approved.html b/web/templates/email/certification_approved.html new file mode 100644 index 0000000..b30afb7 --- /dev/null +++ b/web/templates/email/certification_approved.html @@ -0,0 +1,25 @@ +{{define "content"}} + + + + +

Your certification photo has been approved!

+ +

Dear {{.Data.Username}},

+ +

+ Congrats! Your certification photo has been approved and your profile is + now certified! You can now gain full access to the + website. +

+ +

+ {{.Data.URL}} +

+ +

+ This is an automated e-mail; do not reply to this message. +

+ + +{{end}} \ No newline at end of file diff --git a/web/templates/email/certification_rejected.html b/web/templates/email/certification_rejected.html new file mode 100644 index 0000000..2d93a36 --- /dev/null +++ b/web/templates/email/certification_rejected.html @@ -0,0 +1,32 @@ +{{define "content"}} + + + + +

Your certification photo has been rejected

+ +

Dear {{.Data.Username}},

+ +

+ We regret to inform you that your certification photo has been rejected. An admin has + left the following comment about this: +

+ +

+ {{.Data.AdminComment}} +

+ +

+ Please try uploading a new verification photo at the link below to try again: +

+ +

+ {{.Data.URL}} +

+ +

+ This is an automated e-mail; do not reply to this message. +

+ + +{{end}} \ No newline at end of file diff --git a/web/templates/errors/certification_required.html b/web/templates/errors/certification_required.html new file mode 100644 index 0000000..b58439d --- /dev/null +++ b/web/templates/errors/certification_required.html @@ -0,0 +1,76 @@ +{{define "content"}} +
+
+
+
+

Certification Required

+
+
+
+ +
+

Certification Required

+

+ Your profile must be certified as being a real person before you + are allowed to interact with much of this website. Certification helps protect this + site from spammers, robots, anonymous lurkers and other unsavory characters. +

+ +

+ To access the Certification Required areas you need to upload a Profile Picture + that shows your face and submit a "verification selfie" depicting yourself + holding a hand-written note on paper to prove that you are the person in your + profile picture. +

+ +

Your Certification Checklist

+
+ + + +
+

While You Wait

+ +

+ While waiting for your Certification Photo to be approved, you may + view your profile, + edit your profile and + upload some additional pictures + to your profile. Your additional photos will not be visible to other members + until your profile has been certified. +

+
+
+{{end}} \ No newline at end of file diff --git a/web/templates/index.html b/web/templates/index.html index 5926ec2..c5e748a 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -15,7 +15,7 @@

- Welcome to {{.Title}}, a social network designed for real + Welcome to {{PrettyTitle}}, a social network designed for real nudists and exhibitionists!

diff --git a/web/templates/photo/certification.html b/web/templates/photo/certification.html new file mode 100644 index 0000000..693ba46 --- /dev/null +++ b/web/templates/photo/certification.html @@ -0,0 +1,166 @@ +{{define "title"}}Certification Photo{{end}} +{{define "content"}} +
+
+
+
+

+ Certification Photo +

+
+
+
+ +
+
+
+
+ +
+ +
+ Certification Status: + {{if eq .CertificationPhoto.Status "needed"}} + Awaiting Upload + {{else if eq .CertificationPhoto.Status "pending"}} + Pending Approval + {{else if eq .CertificationPhoto.Status "approved"}} + Approved + {{else if eq .CertificationPhoto.Status "rejected"}} + Rejected + {{else}} + {{.CertificationPhoto.Status}} + {{end}} +
+ + {{if .CertificationPhoto.AdminComment}} +
+

+ Your certification photo has been rejected. Please review the admin comment + below and try taking and uploading a new certification photo. +

+

+ Admin comment: +

+

+ {{.CertificationPhoto.AdminComment}} +

+
+ {{end}} + + {{if .CertificationPhoto.Filename}} +
+ +
+
+
+ {{InputCSRF}} + + +
+ + +

+ If your Certification Photo has been approved (so that your user + account is "Certified"), removing this picture will revert your + account to "Not Certified" status. You would then need to be + re-approved with a new Certification Photo to be recertified. +

+ + +
+
+
+
+ {{end}} + +
+

+ Uploading a certification photo (or "verification selfie") is required to gain + full access to this website. We want to know that only "real" users are here -- + no anonymous lurkers. To get certified, please take a selfie that shows your + face and depicts you holding a sheet of paper with the following information + written on it: +

+ +
    +
  • The name of this website: {{PrettyTitle}}
  • +
  • Your username: {{.CurrentUser.Username}}
  • +
  • Today's date: {{Now.Format "2006/01/02"}}
  • +
+ +

+ Please ensure that your face is visible and your hand is clearly seen + holding the sheet of paper. Your certification photo will not + appear on your photo gallery, and nor should you upload it separately + to your gallery page (as it may enable others to photoshop your image + and use it to verify a fake profile on another website). +

+
+ +
+ {{InputCSRF}} + +
+
Example Picture
+
(ink colors not important)
+ +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+
+
+
+ +
+ + +{{end}} \ No newline at end of file diff --git a/web/templates/photo/user_photos.html b/web/templates/photo/gallery.html similarity index 58% rename from web/templates/photo/user_photos.html rename to web/templates/photo/gallery.html index b61bd2a..0fad3ad 100644 --- a/web/templates/photo/user_photos.html +++ b/web/templates/photo/gallery.html @@ -1,4 +1,12 @@ -{{define "title"}}Photos of {{.User.Username}}{{end}} + +{{define "title"}} +{{if .IsSiteGallery}}Member Gallery{{else}}Photos of {{.User.Username}}{{end}} +{{end}} {{define "card-body"}} @@ -13,14 +21,28 @@ {{end}} + {{if eq .Visibility "public"}} - {{if eq .Visibility "public"}}Public{{end}} - {{if eq .Visibility "private"}}Private{{end}} - {{if eq .Visibility "friends"}}Friends{{end}} + Public + {{else if eq .Visibility "friends"}} + + + + Friends + + + {{else}} + + + + Private + + + {{end}} {{if .Gallery}} @@ -72,10 +94,15 @@
+ {{if .IsSiteGallery}} +

+ {{template "title" .}} +

+ {{else}}

- Photos of {{.User.Username}} + {{template "title" .}}

{{if .IsOwnPhotos}} @@ -89,6 +116,7 @@
{{end}}
+ {{end}}
@@ -96,6 +124,8 @@ {{$Root := .}}
+ + {{if not .IsSiteGallery}}
  • @@ -116,6 +146,7 @@
+ {{end}}