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.
This commit is contained in:
Noah 2022-08-13 15:39:31 -07:00
parent cd1b349fcc
commit 400a256ec8
29 changed files with 1475 additions and 146 deletions

View File

@ -15,10 +15,11 @@ var Current = DefaultVariable()
// Variable configuration attributes (loaded from settings.json). // Variable configuration attributes (loaded from settings.json).
type Variable struct { type Variable struct {
BaseURL string BaseURL string
Mail Mail AdminEmail string
Redis Redis Mail Mail
Database Database Redis Redis
Database Database
} }
// DefaultVariable returns the default settings.json data. // DefaultVariable returns the default settings.json data.

View File

@ -19,6 +19,14 @@ func Settings() http.HandlerFunc {
"Enum": config.ProfileEnums, "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? // Are we POSTing?
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
intent := r.PostFormValue("intent") intent := r.PostFormValue("intent")
@ -30,14 +38,6 @@ func Settings() http.HandlerFunc {
dob = r.PostFormValue("dob") 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. // Set user attributes.
user.Name = &displayName user.Name = &displayName
if len(dob) > 0 { if len(dob) > 0 {
@ -71,6 +71,18 @@ func Settings() http.HandlerFunc {
} }
session.Flash(w, r, "Profile settings updated!") 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": case "settings":
fallthrough fallthrough
default: default:

View File

@ -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
}
})
}

View File

@ -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
}
})
}

View File

@ -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
}
})
}

View File

@ -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. // UserPhotos controller (/photo/u/:username) to view a user's gallery or manage if it's yourself.
func UserPhotos() http.HandlerFunc { 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Query params. // Query params.
var ( var (
@ -51,6 +51,12 @@ func UserPhotos() http.HandlerFunc {
visibility = append(visibility, models.PhotoFriends, models.PhotoPrivate) visibility = append(visibility, models.PhotoFriends, models.PhotoPrivate)
} }
// Explicit photo filter?
explicit := currentUser.Explicit
if isOwnPhotos {
explicit = true
}
// Get the page of photos. // Get the page of photos.
pager := &models.Pagination{ pager := &models.Pagination{
Page: 1, Page: 1,
@ -59,14 +65,21 @@ func UserPhotos() http.HandlerFunc {
} }
pager.ParsePage(r) pager.ParsePage(r)
log.Error("Pager: %+v", pager) 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{}{ var vars = map[string]interface{}{
"IsOwnPhotos": currentUser.ID == user.ID, "IsOwnPhotos": currentUser.ID == user.ID,
"User": user, "User": user,
"Photos": photos, "Photos": photos,
"Pager": pager, "Pager": pager,
"ViewStyle": viewStyle, "ViewStyle": viewStyle,
"ExplicitCount": explicitCount,
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {

View File

@ -3,6 +3,7 @@ package middleware
import ( import (
"net/http" "net/http"
"git.kirsle.net/apps/gosocial/pkg/controller/photo"
"git.kirsle.net/apps/gosocial/pkg/log" "git.kirsle.net/apps/gosocial/pkg/log"
"git.kirsle.net/apps/gosocial/pkg/session" "git.kirsle.net/apps/gosocial/pkg/session"
"git.kirsle.net/apps/gosocial/pkg/templates" "git.kirsle.net/apps/gosocial/pkg/templates"
@ -23,3 +24,48 @@ func LoginRequired(handler http.Handler) http.Handler {
handler.ServeHTTP(w, r) 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)
})
}

View File

@ -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
}

View File

@ -11,4 +11,5 @@ func AutoMigrate() {
DB.AutoMigrate(&User{}) DB.AutoMigrate(&User{})
DB.AutoMigrate(&ProfileField{}) DB.AutoMigrate(&ProfileField{})
DB.AutoMigrate(&Photo{}) DB.AutoMigrate(&Photo{})
DB.AutoMigrate(&CertificationPhoto{})
} }

View File

@ -1,6 +1,7 @@
package models package models
import ( import (
"math"
"net/http" "net/http"
"strconv" "strconv"
@ -11,7 +12,6 @@ import (
type Pagination struct { type Pagination struct {
Page int Page int
PerPage int PerPage int
Pages int
Total int64 Total int64
Sort string Sort string
} }
@ -39,8 +39,11 @@ func (p *Pagination) ParsePage(r *http.Request) {
// Iter the pages, for templates. // Iter the pages, for templates.
func (p *Pagination) Iter() []Page { func (p *Pagination) Iter() []Page {
var pages = []Page{} var (
for i := 1; i <= p.Pages; i++ { pages = []Page{}
total = p.Pages()
)
for i := 1; i <= total; i++ {
pages = append(pages, Page{ pages = append(pages, Page{
Page: i, Page: i,
IsCurrent: i == p.Page, IsCurrent: i == p.Page,
@ -49,12 +52,16 @@ func (p *Pagination) Iter() []Page {
return pages return pages
} }
func (p *Pagination) Pages() int {
return int(math.Ceil(float64(p.Total) / float64(p.PerPage)))
}
func (p *Pagination) GetOffset() int { func (p *Pagination) GetOffset() int {
return (p.Page - 1) * p.PerPage return (p.Page - 1) * p.PerPage
} }
func (p *Pagination) HasNext() bool { func (p *Pagination) HasNext() bool {
return p.Page < p.Pages return p.Page < p.Pages()
} }
func (p *Pagination) HasPrevious() bool { func (p *Pagination) HasPrevious() bool {
@ -62,8 +69,8 @@ func (p *Pagination) HasPrevious() bool {
} }
func (p *Pagination) Next() int { func (p *Pagination) Next() int {
if p.Page >= p.Pages { if p.Page >= p.Pages() {
return p.Pages return p.Pages()
} }
return p.Page + 1 return p.Page + 1
} }

View File

@ -2,8 +2,9 @@ package models
import ( import (
"errors" "errors"
"math"
"time" "time"
"gorm.io/gorm"
) )
// Photo table. // Photo table.
@ -61,20 +62,25 @@ func GetPhoto(id uint64) (*Photo, error) {
/* /*
PaginateUserPhotos gets a page of photos belonging to a user ID. 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 p = []*Photo{}
var explicit = []bool{false}
if explicitOK {
explicit = []bool{true, false}
}
query := DB.Where( query := DB.Where(
"user_id = ? AND visibility IN ?", "user_id = ? AND visibility IN ? AND explicit IN ?",
userID, userID,
visibility, visibility,
explicit,
).Order( ).Order(
pager.Sort, pager.Sort,
) )
// Get the total count. // Get the total count.
query.Model(&Photo{}).Count(&pager.Total) query.Model(&Photo{}).Count(&pager.Total)
pager.Pages = int(math.Ceil(float64(pager.Total) / float64(pager.PerPage)))
result := query.Offset( result := query.Offset(
pager.GetOffset(), pager.GetOffset(),
@ -83,6 +89,52 @@ func PaginateUserPhotos(userID uint64, visibility []PhotoVisibility, pager *Pagi
return p, result.Error 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. // Save photo.
func (p *Photo) Save() error { func (p *Photo) Save() error {
result := DB.Save(p) result := DB.Save(p)

View File

@ -94,6 +94,43 @@ func FindUser(username string) (*User, error) {
return u, result.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. // HashPassword sets the user's hashed (bcrypt) password.
func (u *User) HashPassword(password string) error { func (u *User) HashPassword(password string) error {
passwd, err := bcrypt.GenerateFromPassword([]byte(password), config.BcryptCost) passwd, err := bcrypt.GenerateFromPassword([]byte(password), config.BcryptCost)

View File

@ -6,6 +6,7 @@ import (
"git.kirsle.net/apps/gosocial/pkg/config" "git.kirsle.net/apps/gosocial/pkg/config"
"git.kirsle.net/apps/gosocial/pkg/controller/account" "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/api"
"git.kirsle.net/apps/gosocial/pkg/controller/index" "git.kirsle.net/apps/gosocial/pkg/controller/index"
"git.kirsle.net/apps/gosocial/pkg/controller/photo" "git.kirsle.net/apps/gosocial/pkg/controller/photo"
@ -21,7 +22,7 @@ func New() http.Handler {
mux.HandleFunc("/logout", account.Logout()) mux.HandleFunc("/logout", account.Logout())
mux.HandleFunc("/signup", account.Signup()) 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("/me", middleware.LoginRequired(account.Dashboard()))
mux.Handle("/settings", middleware.LoginRequired(account.Settings())) mux.Handle("/settings", middleware.LoginRequired(account.Settings()))
mux.Handle("/u/", middleware.LoginRequired(account.Profile())) 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/u/", middleware.LoginRequired(photo.UserPhotos()))
mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit())) mux.Handle("/photo/edit", middleware.LoginRequired(photo.Edit()))
mux.Handle("/photo/delete", middleware.LoginRequired(photo.Delete())) 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. // JSON API endpoints.
mux.HandleFunc("/v1/version", api.Version()) mux.HandleFunc("/v1/version", api.Version())

View File

@ -23,6 +23,24 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
"Split": strings.Split, "Split": strings.Split,
"ToMarkdown": ToMarkdown, "ToMarkdown": ToMarkdown,
"PhotoURL": photo.URLPath, "PhotoURL": photo.URLPath,
"Now": time.Now,
"PrettyTitle": func() template.HTML {
return template.HTML(fmt.Sprintf(
`<strong style="color: #0077FF">non</strong>` +
`<strong style="color: #FF77FF">shy</strong>`,
))
},
"Pluralize64": func(count int64, labels ...string) string {
if len(labels) < 2 {
labels = []string{"", "s"}
}
if count == 1 {
return labels[0]
} else {
return labels[1]
}
},
} }
} }

View File

@ -20,9 +20,15 @@
right: 0; right: 0;
} }
/* Photo modals in addition to Bulma .modal */ /* Photo modals in addition to Bulma .modal-content */
.photo-modal { .photo-modal {
width: auto !important; width: auto !important;
max-width: fit-content; max-width: fit-content;
max-height: fit-content; max-height: fit-content;
} }
/* Custom bulma tag colors */
.tag:not(body).is-private.is-light {
color: #CC00CC;
background-color: #FFEEFF;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -12,7 +12,62 @@
<div class="block p-4"> <div class="block p-4">
<div class="columns"> <div class="columns">
<div class="column"> <div class="column">
<div class="card"> <!-- Onboarding Checklist -->
{{if or (not .CurrentUser.Certified) (not .CurrentUser.ProfilePhoto.ID)}}
<div class="card block">
<header class="card-header has-background-danger">
<p class="card-header-title has-text-light">
<span class="icon"><i class="fa fa-check"></i></span>
<span>Onboarding Checklist</span>
</p>
</header>
<div class="card-content">
<p class="block">
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.
</p>
<ul class="menu-list block">
<li>
<a href="/photo/upload?intent=profile_pic">
{{if .CurrentUser.ProfilePhoto.ID}}
<span class="icon"><i class="fa fa-circle-check has-text-success"></i></span>
{{else}}
<span class="icon"><i class="fa fa-circle has-text-danger"></i></span>
{{end}}
<span>
Add a Profile Picture
{{if not .CurrentUser.ProfilePhoto.ID}}
<span class="icon"><i class="fa fa-external-link"></i></span>
{{end}}
</span>
</a>
</li>
<li>
<a href="/photo/certification">
{{if .CurrentUser.Certified}}
<span class="icon"><i class="fa fa-circle-check has-text-success"></i></span>
{{else}}
<span class="icon"><i class="fa fa-circle has-text-danger"></i></span>
{{end}}
<span>
Get certified by uploading a verification selfie
{{if not .CurrentUser.Certified}}
<span class="icon"><i class="fa fa-external-link"></i></span>
{{end}}
</span>
</a>
</li>
</ul>
</div>
</div>
{{end}}
<div class="card block">
<header class="card-header has-background-link"> <header class="card-header has-background-link">
<p class="card-header-title has-text-light">My Account</p> <p class="card-header-title has-text-light">My Account</p>
</header> </header>

View File

@ -8,7 +8,7 @@
<div class="column is-narrow"> <div class="column is-narrow">
<figure class="profile-photo"> <figure class="profile-photo">
{{if .User.ProfilePhoto.ID}} {{if .User.ProfilePhoto.ID}}
<img src="/static/photos/{{.User.ProfilePhoto.CroppedFilename}}"> <img src="/static/photos/{{.User.ProfilePhoto.CroppedFilename}}" data-photo-id="{{.User.ProfilePhoto.ID}}">
{{else}} {{else}}
<img class="is-rounded" src="/static/img/shy.png"> <img class="is-rounded" src="/static/img/shy.png">
{{end}} {{end}}
@ -52,20 +52,31 @@
</div> </div>
{{if .User.Certified}} {{if .User.Certified}}
<div class="pt-1"> <div class="pt-1">
<div class="icon-text"> <div class="icon-text" title="This user has been certified via a verification selfie.">
<span class="icon"> <span class="icon">
<i class="fa-solid fa-certificate has-text-success"></i> <i class="fa-solid fa-certificate has-text-success"></i>
</span> </span>
<strong class="has-text-success">Verified!</strong> <strong class="has-text-success">Certified!</strong>
</div> </div>
</div> </div>
{{else}} {{else}}
<div class="pt-1"> <div class="pt-1">
<div class="icon-text"> <div class="icon-text" title="This user has not certified themselves with a verification selfie.">
<span class="icon"> <span class="icon">
<i class="fa-solid fa-certificate has-text-danger"></i> <i class="fa-solid fa-certificate has-text-danger"></i>
</span> </span>
<strong class="has-text-danger">Not verified!</strong> <strong class="has-text-danger">Not certified!</strong>
</div>
</div>
{{end}}
{{if .User.IsAdmin}}
<div class="pt-1">
<div class="icon-text has-text-danger">
<span class="icon">
<i class="fa fa-gavel"></i>
</span>
<strong>Admin</strong>
</div> </div>
</div> </div>
{{end}} {{end}}

View File

@ -217,30 +217,45 @@
</div> </div>
</div> </div>
<div class="card block" id="prefs"> <!-- Website Preferences -->
<header class="card-header has-background-success"> <form method="POST" action="/settings">
<p class="card-header-title"> <input type="hidden" name="intent" value="preferences">
<i class="fa fa-square-check pr-2"></i> {{InputCSRF}}
Website Preferences
</p>
</header>
<div class="card-content"> <div class="card block" id="prefs">
<div class="field"> <header class="card-header has-background-success">
<label class="label">Explicit Content Filter</label> <p class="card-header-title">
<label class="checkbox"> <i class="fa fa-square-check pr-2"></i>
<input type="checkbox" Website Preferences
value="true">
Show explicit content
</label>
<p class="help">
Check this box if you are OK seeing explicit content on this site, which may
include erections or sexually charged content.
</p> </p>
</header>
<div class="card-content">
<div class="field">
<label class="label">Explicit Content Filter</label>
<label class="checkbox">
<input type="checkbox"
name="explicit"
value="true"
{{if .CurrentUser.Explicit}}checked{{end}}>
Show explicit content
</label>
<p class="help">
Check this box if you are OK seeing explicit content on this site, which may
include erections or sexually charged content.
</p>
</div>
<div class="field">
<button type="submit" class="button is-primary">
Save Website Preferences
</button>
</div>
</div> </div>
</div> </div>
</div> </form>
<!-- Account Settings -->
<form method="POST" action="/settings"> <form method="POST" action="/settings">
<input type="hidden" name="intent" value="settings"> <input type="hidden" name="intent" value="settings">
{{InputCSRF}} {{InputCSRF}}

View File

@ -0,0 +1,84 @@
{{define "title"}}Admin - Certification Photos{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-danger is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
Admin / Certification Photos
</h1>
</div>
</div>
</section>
<div class="block p-4">
<div class="block">
There {{Pluralize64 .Pager.Total "is" "are"}} <strong>{{.Pager.Total}}</strong> Certification Photo{{Pluralize64 .Pager.Total}} needing approval.
</div>
{{$Root := .}}
<div class="columns is-multiline">
{{range .Photos}}
<div class="column is-one-third">
{{$User := $Root.UserMap.Get .UserID}}
<form action="{{$Root.Request.URL.Path}}" method="POST">
{{InputCSRF}}
<input type="hidden" name="user_id" value="{{$User.ID}}">
<div class="card" style="max-width: 512px">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
<span class="icon"><i class="fa fa-user"></i></span>
<span>{{or $User.Username "[deleted]"}}</span>
</p>
</header>
<div class="card-image">
<figure class="image">
<img src="{{PhotoURL .Filename}}">
</figure>
</div>
<div class="card-content">
<div class="media block">
<div class="media-left">
<figure class="image is-48x48">
{{if $User.ProfilePhoto.ID}}
<img src="{{PhotoURL $User.ProfilePhoto.CroppedFilename}}">
{{else}}
<img src="/static/img/shy.png">
{{end}}
</figure>
</div>
<div class="media-content">
<p class="title is-4">{{or $User.Name "(no name)"}}</p>
<p class="subtitle is-6">
<span class="icon"><i class="fa fa-user"></i></span>
<a href="/u/{{$User.Username}}" target="_blank">{{$User.Username}}</a>
</p>
</div>
</div>
<div class="field">
<textarea class="textarea" name="comment"
cols="60" rows="2"
placeholder="Admin comment (for rejection)"></textarea>
</div>
</div>
<footer class="card-footer">
<button type="submit" name="verdict" value="reject" class="card-footer-item button is-danger">
<span class="icon"><i class="fa fa-xmark"></i></span>
<span>Reject</span>
</button>
<button type="submit" name="verdict" value="approve" class="card-footer-item button is-success">
<span class="icon"><i class="fa fa-check"></i></span>
<span>Approve</span>
</button>
</footer>
</div>
</form>
</div>
{{end}}
</div>
</div>
</div>
{{end}}

View File

@ -0,0 +1,49 @@
{{define "content"}}
<div class="container">
<section class="hero is-danger is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">Admin Dashboard</h1>
</div>
</div>
</section>
<div class="block p-4">
<div class="columns">
<div class="column">
<div class="card block">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
<span class="icon"><i class="fa fa-gavel"></i></span>
Admin Dashboard
</p>
</header>
<div class="card-content">
<ul class="menu-list">
<li>
<a href="/admin/photo/certification">
<span class="icon"><i class="fa fa-badge"></i></span>
Certification Photos
</a>
</li>
</ul>
</div>
</div>
</div>
<div class="column">
<div class="card">
<header class="card-header has-background-warning">
<p class="card-header-title">Notifications</p>
</header>
<div class="card-content">
TBD.
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}

View File

@ -11,101 +11,138 @@
<title>{{template "title" .}} - {{ .Title }}</title> <title>{{template "title" .}} - {{ .Title }}</title>
</head> </head>
<body> <body>
<div class="container is-fullhd"> <nav class="navbar" role="navigation" aria-label="main navigation">
<nav class="navbar" role="navigation" aria-label="main navigation"> <div class="navbar-brand">
<div class="navbar-brand"> <a class="navbar-item" href="/">
{{ PrettyTitle }}
</a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarBasicExample" class="navbar-menu">
<div class="navbar-start">
{{if not .LoggedIn}}
<a class="navbar-item" href="/"> <a class="navbar-item" href="/">
{{ .Title }} <span class="icon"><i class="fa fa-home"></i></span>
<span>Home</span>
</a> </a>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample"> <a class="navbar-item" href="/about">
<span aria-hidden="true"></span> About
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a> </a>
{{end}}
{{if .LoggedIn}}
<a class="navbar-item" href="/me">
<span class="icon"><i class="fa fa-house-user"></i></span>
<span>Home</span>
</a>
<a class="navbar-item" href="/photo/gallery">
<span class="icon"><i class="fa fa-image"></i></span>
<span>Gallery</span>
</a>
<a class="navbar-item" href="/forums">
<span class="icon"><i class="fa fa-comments"></i></span>
<span>Forums</span>
</a>
<a class="navbar-item" href="/friends">
<span class="icon"><i class="fa fa-user-group"></i></span>
<span>Friends</span>
<span class="tag is-warning">42</span>
</a>
<a class="navbar-item" href="/messages">
<span class="icon"><i class="fa fa-envelope"></i></span>
<span>Messages</span>
<span class="tag is-warning">42</span>
</a>
{{end}}
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
More
</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="/about">
About
</a>
<a class="navbar-item" href="/faq">
FAQ
</a>
<a class="navbar-item" href="/tos">
Terms of Service
</a>
<a class="navbar-item" href="/privacy">
Privacy Policy
</a>
<a class="navbar-item">
Jobs
</a>
<a class="navbar-item" href="/contact">
Contact
</a>
<hr class="navbar-divider">
<a class="navbar-item" href="/feedback">
Report an issue
</a>
</div>
</div>
</div> </div>
<div id="navbarBasicExample" class="navbar-menu"> <div class="navbar-end">
<div class="navbar-start"> {{if .LoggedIn }}
<a class="navbar-item" href="/">
Home
</a>
<a class="navbar-item" href="/about">
About
</a>
{{if .LoggedIn}}
<a class="navbar-item" href="/forums">
Forums
</a>
<a class="navbar-item" href="/messages">
Messages
<span class="tag is-warning">42</span>
</a>
{{end}}
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link"> <a class="navbar-link" href="/me">
More <div class="columns is-mobile is-gapless">
<div class="column is-narrow">
<figure class="image is-24x24 mr-2">
{{if gt .CurrentUser.ProfilePhoto.ID 0}}
<img src="{{PhotoURL .CurrentUser.ProfilePhoto.CroppedFilename}}" class="is-rounded">
{{else}}
<img src="/static/img/shy.png" class="is-rounded has-background-warning">
{{end}}
</figure>
</div>
<div class="column">{{.CurrentUser.Username}}</div>
</div>
</a> </a>
<div class="navbar-dropdown"> <div class="navbar-dropdown is-right">
<a class="navbar-item"> <a class="navbar-item" href="/me">Dashboard</a>
About <a class="navbar-item" href="/u/{{.CurrentUser.Username}}">My Profile</a>
<a class="navbar-item" href="/settings">Settings</a>
{{if .CurrentUser.IsAdmin}}
<a class="navbar-item has-text-danger" href="/admin">Admin</a>
{{end}}
<a class="navbar-item" href="/logout">Log out</a>
</div>
</div>
{{ else }}
<div class="navbar-item">
<div class="buttons">
<a class="button is-primary" href="/signup">
<strong>Sign up</strong>
</a> </a>
<a class="navbar-item"> <a class="button is-light" href="/login">
Jobs Log in
</a>
<a class="navbar-item">
Contact
</a>
<hr class="navbar-divider">
<a class="navbar-item">
Report an issue
</a> </a>
</div> </div>
</div> </div>
</div> {{end}}
<div class="navbar-end">
{{if .LoggedIn }}
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="/me">
<figure class="image is-24x24 mr-2">
{{if gt .CurrentUser.ProfilePhoto.ID 0}}
<img src="{{PhotoURL .CurrentUser.ProfilePhoto.CroppedFilename}}" class="is-rounded">
{{else}}
<img src="/static/img/shy.png" class="is-rounded has-background-warning">
{{end}}
</figure>
{{.CurrentUser.Username}}
</a>
<div class="navbar-dropdown is-right">
<a class="navbar-item" href="/me">Dashboard</a>
<a class="navbar-item" href="/u/{{.CurrentUser.Username}}">My Profile</a>
<a class="navbar-item" href="/settings">Settings</a>
<a class="navbar-item" href="/logout">Log out</a>
</div>
</div>
{{ else }}
<div class="navbar-item">
<div class="buttons">
<a class="button is-primary" href="/signup">
<strong>Sign up</strong>
</a>
<a class="button is-light" href="/login">
Log in
</a>
</div>
</div>
{{end}}
</div>
</div> </div>
</nav> </div>
</nav>
<div class="container is-fullhd">
{{if .Flashes}} {{if .Flashes}}
<div class="notification block is-success"> <div class="notification block is-success">
<!-- <button class="delete"></button> --> <!-- <button class="delete"></button> -->

View File

@ -0,0 +1,23 @@
{{define "content"}}
<html>
<body bakground="#ffffff" color="#000000" link="#0000FF" vlink="#990099" alink="#FF0000">
<basefont face="Arial,Helvetica,sans-serif" size="3" color="#000000"></basefont>
<h1>New Certification Photo Needs Approval</h1>
<p>
The user <strong>{{.Data.User.Username}}</strong> has uploaded a Certification Photo
and it needs admin approval. Click the link below to view pending Certification
Photos:
</p>
<p>
<a href="{{.Data.URL}}" target="_blank">{{.Data.URL}}</a>
</p>
<p>
This is an automated e-mail; do not reply to this message.
</p>
</body>
</html>
{{end}}

View File

@ -0,0 +1,25 @@
{{define "content"}}
<html>
<body bakground="#ffffff" color="#000000" link="#0000FF" vlink="#990099" alink="#FF0000">
<basefont face="Arial,Helvetica,sans-serif" size="3" color="#000000"></basefont>
<h1>Your certification photo has been approved!</h1>
<p>Dear {{.Data.Username}},</p>
<p>
Congrats! Your certification photo has been approved and your profile is
now <strong>certified!</strong> You can now gain full access to the
website.
</p>
<p>
<a href="{{.Data.URL}}" target="_blank">{{.Data.URL}}</a>
</p>
<p>
This is an automated e-mail; do not reply to this message.
</p>
</body>
</html>
{{end}}

View File

@ -0,0 +1,32 @@
{{define "content"}}
<html>
<body bakground="#ffffff" color="#000000" link="#0000FF" vlink="#990099" alink="#FF0000">
<basefont face="Arial,Helvetica,sans-serif" size="3" color="#000000"></basefont>
<h1>Your certification photo has been rejected</h1>
<p>Dear {{.Data.Username}},</p>
<p>
We regret to inform you that your certification photo has been rejected. An admin has
left the following comment about this:
</p>
<p>
{{.Data.AdminComment}}
</p>
<p>
Please try uploading a new verification photo at the link below to try again:
</p>
<p>
<a href="{{.Data.URL}}" target="_blank">{{.Data.URL}}</a>
</p>
<p>
This is an automated e-mail; do not reply to this message.
</p>
</body>
</html>
{{end}}

View File

@ -0,0 +1,76 @@
{{define "content"}}
<div class="container">
<section class="hero block is-danger is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">Certification Required</h1>
</div>
</div>
</section>
<div class="block content p-4 mb-0">
<h1>Certification Required</h1>
<p>
Your profile must be <strong>certified</strong> 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.
</p>
<p>
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.
</p>
<h3>Your Certification Checklist</h3>
</div>
<ul class="menu-list block">
<li>
<a href="/photo/upload?intent=profile_pic">
{{if .CurrentUser.ProfilePhoto.ID}}
<span class="icon"><i class="fa fa-circle-check has-text-success"></i></span>
{{else}}
<span class="icon"><i class="fa fa-circle has-text-danger"></i></span>
{{end}}
<span>
Upload a Profile Picture to your account that shows your face
{{if not .CurrentUser.ProfilePhoto.ID}}
<span class="icon"><i class="fa fa-external-link"></i></span>
{{end}}
</span>
</a>
</li>
<li>
<a href="/photo/certification">
{{if .CurrentUser.Certified}}
<span class="icon"><i class="fa fa-circle-check has-text-success"></i></span>
{{else}}
<span class="icon"><i class="fa fa-circle has-text-danger"></i></span>
{{end}}
<span>
Get certified by uploading a verification selfie
{{if not .CurrentUser.Certified}}
<span class="icon"><i class="fa fa-external-link"></i></span>
{{end}}
</span>
</a>
</li>
</ul>
<div class="content p-4">
<h3>While You Wait</h3>
<p>
While waiting for your Certification Photo to be approved, you may
<a href="/u/{{.CurrentUser.Username}}">view your profile</a>,
<a href="/settings">edit your profile</a> and
<a href="/photo/u/{{.CurrentUser.Username}}">upload some additional pictures</a>
to your profile. Your additional photos will not be visible to other members
until your profile has been certified.
</p>
</div>
</div>
{{end}}

View File

@ -15,7 +15,7 @@
<div class="columns"> <div class="columns">
<div class="column content is-three-quarters p-4"> <div class="column content is-three-quarters p-4">
<p> <p>
Welcome to <strong>{{.Title}}</strong>, a social network designed for <strong>real</strong> Welcome to <strong>{{PrettyTitle}}</strong>, a social network designed for <strong>real</strong>
nudists and exhibitionists! nudists and exhibitionists!
</p> </p>

View File

@ -0,0 +1,166 @@
{{define "title"}}Certification Photo{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-info is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
Certification Photo
</h1>
</div>
</div>
</section>
<div class="block p-4">
<div class="level">
<div class="level-item">
<div class="card" style="width: 100%; max-width: 640px">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
<span class="icon"><i class="fa fa-image-portrait"></i></span>
Certification Photo
</p>
</header>
<div class="card-content">
<div class="block">
<strong>Certification Status:</strong>
{{if eq .CertificationPhoto.Status "needed"}}
<span class="tag is-warning">Awaiting Upload</span>
{{else if eq .CertificationPhoto.Status "pending"}}
<span class="tag is-info">Pending Approval</span>
{{else if eq .CertificationPhoto.Status "approved"}}
<span class="tag is-success">Approved</span>
{{else if eq .CertificationPhoto.Status "rejected"}}
<span class="tag is-danger">Rejected</span>
{{else}}
<span class="tag is-danger">{{.CertificationPhoto.Status}}</span>
{{end}}
</div>
{{if .CertificationPhoto.AdminComment}}
<div class="notification is-warning content">
<p>
Your certification photo has been rejected. Please review the admin comment
below and try taking and uploading a new certification photo.
</p>
<p>
<strong>Admin comment:</strong>
</p>
<p>
{{.CertificationPhoto.AdminComment}}
</p>
</div>
{{end}}
{{if .CertificationPhoto.Filename}}
<div class="image block">
<img src="{{PhotoURL .CertificationPhoto.Filename}}">
</div>
<div class="block">
<form action="/photo/certification" method="POST">
{{InputCSRF}}
<input type="hidden" name="delete" value="true">
<div class="field">
<label class="label has-text-danger">Delete Photo</label>
<p class="help block">
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.
</p>
<button type="submit" class="button is-danger">
<span class="icon"><i class="fa fa-trash"></i></span>
<span>Delete This Photo</span>
</button>
</div>
</form>
</div>
<hr class="block">
{{end}}
<div class="block content">
<p>
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:
</p>
<ul>
<li>The name of this website: {{PrettyTitle}}</li>
<li>Your username: <strong>{{.CurrentUser.Username}}</strong></li>
<li>Today's date: <strong>{{Now.Format "2006/01/02"}}</strong></li>
</ul>
<p>
Please ensure that your face is visible and your hand is clearly seen
holding the sheet of paper. Your certification photo <strong>will not</strong>
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).
</p>
</div>
<form method="POST" action="/photo/certification" enctype="multipart/form-data">
{{InputCSRF}}
<div class="has-text-centered block">
<div><strong>Example Picture</strong></div>
<div><small class="has-text-grey">(ink colors not important)</small></div>
<img src="/static/img/certification-example.jpg">
</div>
<div class="field">
<label for="file" class="label">Browse and select your verification photo:</label>
<div class="file has-name is-fullwidth">
<label class="file-label">
<input class="file-input" type="file"
name="file"
id="file"
accept=".jpg,.jpeg,.jpe,.png"
required>
<span class="file-cta">
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
<span class="file-label">
Choose a file…
</span>
</span>
<span class="file-name" id="fileName">
Select a file
</span>
</label>
</div>
</div>
<div class="block has-text-centered">
<button type="submit" class="button is-primary">Upload Certification Photo</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
window.addEventListener("DOMContentLoaded", (event) => {
let $file = document.querySelector("#file"),
$fileName = document.querySelector("#fileName");
$file.addEventListener("change", function() {
let file = this.files[0];
$fileName.innerHTML = file.name;
});
});
</script>
{{end}}

View File

@ -1,4 +1,12 @@
{{define "title"}}Photos of {{.User.Username}}{{end}} <!--
Photo Gallery Template, shared by Site Photos + User Photos.
When Site Gallery: .IsSiteGallery is defined and true.
When User Gallery: .User is defined, .IsOwnPhotos may be.
-->
{{define "title"}}
{{if .IsSiteGallery}}Member Gallery{{else}}Photos of {{.User.Username}}{{end}}
{{end}}
<!-- Reusable card body --> <!-- Reusable card body -->
{{define "card-body"}} {{define "card-body"}}
@ -13,14 +21,28 @@
</span> </span>
{{end}} {{end}}
{{if eq .Visibility "public"}}
<span class="tag is-info is-light"> <span class="tag is-info is-light">
<span class="icon"><i class="fa fa-eye"></i></span> <span class="icon"><i class="fa fa-eye"></i></span>
<span> <span>
{{if eq .Visibility "public"}}Public{{end}} Public
{{if eq .Visibility "private"}}Private{{end}}
{{if eq .Visibility "friends"}}Friends{{end}}
</span> </span>
</span> </span>
{{else if eq .Visibility "friends"}}
<span class="tag is-warning is-light">
<span class="icon"><i class="fa fa-eye"></i></span>
<span>
Friends
</span>
</span>
{{else}}
<span class="tag is-private is-light">
<span class="icon"><i class="fa fa-eye"></i></span>
<span>
Private
</span>
</span>
{{end}}
{{if .Gallery}} {{if .Gallery}}
<span class="tag is-success is-light"> <span class="tag is-success is-light">
@ -72,10 +94,15 @@
<div class="container"> <div class="container">
<section class="hero is-info is-bold"> <section class="hero is-info is-bold">
<div class="hero-body"> <div class="hero-body">
{{if .IsSiteGallery}}
<h1 class="title">
{{template "title" .}}
</h1>
{{else}}
<div class="level"> <div class="level">
<div class="level-left"> <div class="level-left">
<h1 class="title"> <h1 class="title">
Photos of {{.User.Username}} {{template "title" .}}
</h1> </h1>
</div> </div>
{{if .IsOwnPhotos}} {{if .IsOwnPhotos}}
@ -89,6 +116,7 @@
</div> </div>
{{end}} {{end}}
</div> </div>
{{end}}
</div> </div>
</section> </section>
@ -96,6 +124,8 @@
{{$Root := .}} {{$Root := .}}
<div class="block p-4"> <div class="block p-4">
<!-- Profile Tab for user view -->
{{if not .IsSiteGallery}}
<div class="tabs is-boxed"> <div class="tabs is-boxed">
<ul> <ul>
<li> <li>
@ -116,6 +146,7 @@
</li> </li>
</ul> </ul>
</div> </div>
{{end}}
<!-- Photo Detail Modal --> <!-- Photo Detail Modal -->
<div class="modal" id="detail-modal"> <div class="modal" id="detail-modal">
@ -133,9 +164,11 @@
<div class="level-left"> <div class="level-left">
<div class="level-item"> <div class="level-item">
<span> <span>
Found <strong>{{.Pager.Total}}</strong> photos (page {{.Pager.Page}} of {{.Pager.Pages}}). Found <strong>{{.Pager.Total}}</strong> photo{{Pluralize64 .Pager.Total}} (page {{.Pager.Page}} of {{.Pager.Pages}}).
{{if .ExplicitCount}}
{{.ExplicitCount}} explicit photo{{Pluralize64 .ExplicitCount}} hidden per your settings.
{{end}}
</span> </span>
</div> </div>
</div> </div>
@ -161,17 +194,55 @@
{{if eq .ViewStyle "full"}} {{if eq .ViewStyle "full"}}
{{range .Photos}} {{range .Photos}}
<div class="card block"> <div class="card block">
<header class="card-header has-background-link"> <header class="card-header {{if .Explicit}}has-background-danger{{else}}has-background-link{{end}}">
<!-- Site Gallery header -->
{{if $Root.IsSiteGallery}}
<div class="card-header-title has-text-light">
{{if $Root.UserMap.Has .UserID}}
{{$Owner := $Root.UserMap.Get .UserID}}
<div class="columns is-mobile is-gapless">
<div class="column is-narrow">
<figure class="image is-24x24 mr-2">
{{if gt $Owner.ProfilePhoto.ID 0}}
<img src="{{PhotoURL $Owner.ProfilePhoto.CroppedFilename}}" class="is-rounded">
{{else}}
<img src="/static/img/shy.png" class="is-rounded has-background-warning">
{{end}}
</figure>
</div>
<div class="column">
<a href="/u/{{$Owner.Username}}" class="has-text-light">
{{$Owner.Username}}
<i class="fa fa-external-link ml-2"></i>
</a>
</div>
</div>
{{else}}
<span class="fa fa-user mr-2"></span>
<span>[deleted]</span>
{{end}}
</div>
{{else}}
<!-- User Gallery Full Header -->
<p class="card-header-title has-text-light"> <p class="card-header-title has-text-light">
<span class="icon"> <span class="icon">
<i class="fa fa-image"></i> <i class="fa fa-image"></i>
</span> </span>
{{or .Caption "Photo"}} {{or .Caption "Photo"}}
</p> </p>
{{end}}
</header> </header>
<div class="card-image">
<figure class="image">
<img src="{{PhotoURL .Filename}}">
</figure>
</div>
<div class="card-content"> <div class="card-content">
<img src="{{PhotoURL .Filename}}"> {{if .Caption}}
{{.Caption}}
{{else}}<em>No caption</em>{{end}}
{{template "card-body" .}} {{template "card-body" .}}
</div> </div>
@ -187,6 +258,37 @@
{{range .Photos}} {{range .Photos}}
<div class="column is-one-quarter-desktop is-half-tablet"> <div class="column is-one-quarter-desktop is-half-tablet">
<div class="card"> <div class="card">
<!-- Header only on Site Gallery version -->
{{if $Root.IsSiteGallery}}
<header class="card-header {{if .Explicit}}has-background-danger{{else}}has-background-link{{end}}">
<div class="card-header-title has-text-light">
{{if $Root.UserMap.Has .UserID}}
{{$Owner := $Root.UserMap.Get .UserID}}
<div class="columns is-mobile is-gapless">
<div class="column is-narrow">
<figure class="image is-24x24 mr-2">
{{if gt $Owner.ProfilePhoto.ID 0}}
<img src="{{PhotoURL $Owner.ProfilePhoto.CroppedFilename}}" class="is-rounded">
{{else}}
<img src="/static/img/shy.png" class="is-rounded has-background-warning">
{{end}}
</figure>
</div>
<div class="column">
<a href="/u/{{$Owner.Username}}" class="has-text-light">
{{$Owner.Username}}
<i class="fa fa-external-link ml-2"></i>
</a>
</div>
</div>
{{else}}
<span class="fa fa-user mr-2"></span>
<span>[deleted]</span>
{{end}}
</div>
</header>
{{end}}
<div class="card-image"> <div class="card-image">
<figure class="image"> <figure class="image">
<a href="{{PhotoURL .Filename}}" target="_blank" <a href="{{PhotoURL .Filename}}" target="_blank"