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 @@
+ + 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. +
+ + +My Account
- - Website Preferences -
-