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 0000000..a1c2235 Binary files /dev/null and b/web/static/img/certification-example.jpg differ 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}}