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:
parent
4533c15747
commit
f6d076f7c2
|
@ -16,6 +16,7 @@ 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
|
||||||
|
AdminEmail string
|
||||||
Mail Mail
|
Mail Mail
|
||||||
Redis Redis
|
Redis Redis
|
||||||
Database Database
|
Database Database
|
||||||
|
|
|
@ -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:
|
||||||
|
|
18
pkg/controller/admin/dashboard.go
Normal file
18
pkg/controller/admin/dashboard.go
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
303
pkg/controller/photo/certification.go
Normal file
303
pkg/controller/photo/certification.go
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
61
pkg/controller/photo/site_gallery.go
Normal file
61
pkg/controller/photo/site_gallery.go
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -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,7 +65,13 @@ 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,
|
||||||
|
@ -67,6 +79,7 @@ func UserPhotos() http.HandlerFunc {
|
||||||
"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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
70
pkg/models/certification.go
Normal file
70
pkg/models/certification.go
Normal 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
|
||||||
|
}
|
|
@ -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{})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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]
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
BIN
web/static/img/certification-example.jpg
Normal file
BIN
web/static/img/certification-example.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
|
@ -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>
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
|
@ -217,6 +217,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Website Preferences -->
|
||||||
|
<form method="POST" action="/settings">
|
||||||
|
<input type="hidden" name="intent" value="preferences">
|
||||||
|
{{InputCSRF}}
|
||||||
|
|
||||||
<div class="card block" id="prefs">
|
<div class="card block" id="prefs">
|
||||||
<header class="card-header has-background-success">
|
<header class="card-header has-background-success">
|
||||||
<p class="card-header-title">
|
<p class="card-header-title">
|
||||||
|
@ -230,7 +235,9 @@
|
||||||
<label class="label">Explicit Content Filter</label>
|
<label class="label">Explicit Content Filter</label>
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
value="true">
|
name="explicit"
|
||||||
|
value="true"
|
||||||
|
{{if .CurrentUser.Explicit}}checked{{end}}>
|
||||||
Show explicit content
|
Show explicit content
|
||||||
</label>
|
</label>
|
||||||
<p class="help">
|
<p class="help">
|
||||||
|
@ -238,9 +245,17 @@
|
||||||
include erections or sexually charged content.
|
include erections or sexually charged content.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<button type="submit" class="button is-primary">
|
||||||
|
Save Website Preferences
|
||||||
|
</button>
|
||||||
|
</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}}
|
||||||
|
|
84
web/templates/admin/certification.html
Normal file
84
web/templates/admin/certification.html
Normal 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}}
|
49
web/templates/admin/dashboard.html
Normal file
49
web/templates/admin/dashboard.html
Normal 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}}
|
|
@ -11,11 +11,10 @@
|
||||||
<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="/">
|
<a class="navbar-item" href="/">
|
||||||
{{ .Title }}
|
{{ PrettyTitle }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
|
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample">
|
||||||
|
@ -27,21 +26,42 @@
|
||||||
|
|
||||||
<div id="navbarBasicExample" class="navbar-menu">
|
<div id="navbarBasicExample" class="navbar-menu">
|
||||||
<div class="navbar-start">
|
<div class="navbar-start">
|
||||||
|
{{if not .LoggedIn}}
|
||||||
<a class="navbar-item" href="/">
|
<a class="navbar-item" href="/">
|
||||||
Home
|
<span class="icon"><i class="fa fa-home"></i></span>
|
||||||
|
<span>Home</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="navbar-item" href="/about">
|
<a class="navbar-item" href="/about">
|
||||||
About
|
About
|
||||||
</a>
|
</a>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .LoggedIn}}
|
{{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">
|
<a class="navbar-item" href="/forums">
|
||||||
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>
|
||||||
|
|
||||||
<a class="navbar-item" href="/messages">
|
<a class="navbar-item" href="/messages">
|
||||||
Messages
|
<span class="icon"><i class="fa fa-envelope"></i></span>
|
||||||
|
<span>Messages</span>
|
||||||
<span class="tag is-warning">42</span>
|
<span class="tag is-warning">42</span>
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
@ -52,17 +72,26 @@
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="navbar-dropdown">
|
<div class="navbar-dropdown">
|
||||||
<a class="navbar-item">
|
<a class="navbar-item" href="/about">
|
||||||
About
|
About
|
||||||
</a>
|
</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">
|
<a class="navbar-item">
|
||||||
Jobs
|
Jobs
|
||||||
</a>
|
</a>
|
||||||
<a class="navbar-item">
|
<a class="navbar-item" href="/contact">
|
||||||
Contact
|
Contact
|
||||||
</a>
|
</a>
|
||||||
<hr class="navbar-divider">
|
<hr class="navbar-divider">
|
||||||
<a class="navbar-item">
|
<a class="navbar-item" href="/feedback">
|
||||||
Report an issue
|
Report an issue
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -73,6 +102,8 @@
|
||||||
{{if .LoggedIn }}
|
{{if .LoggedIn }}
|
||||||
<div class="navbar-item has-dropdown is-hoverable">
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
<a class="navbar-link" href="/me">
|
<a class="navbar-link" href="/me">
|
||||||
|
<div class="columns is-mobile is-gapless">
|
||||||
|
<div class="column is-narrow">
|
||||||
<figure class="image is-24x24 mr-2">
|
<figure class="image is-24x24 mr-2">
|
||||||
{{if gt .CurrentUser.ProfilePhoto.ID 0}}
|
{{if gt .CurrentUser.ProfilePhoto.ID 0}}
|
||||||
<img src="{{PhotoURL .CurrentUser.ProfilePhoto.CroppedFilename}}" class="is-rounded">
|
<img src="{{PhotoURL .CurrentUser.ProfilePhoto.CroppedFilename}}" class="is-rounded">
|
||||||
|
@ -80,13 +111,18 @@
|
||||||
<img src="/static/img/shy.png" class="is-rounded has-background-warning">
|
<img src="/static/img/shy.png" class="is-rounded has-background-warning">
|
||||||
{{end}}
|
{{end}}
|
||||||
</figure>
|
</figure>
|
||||||
{{.CurrentUser.Username}}
|
</div>
|
||||||
|
<div class="column">{{.CurrentUser.Username}}</div>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="navbar-dropdown is-right">
|
<div class="navbar-dropdown is-right">
|
||||||
<a class="navbar-item" href="/me">Dashboard</a>
|
<a class="navbar-item" href="/me">Dashboard</a>
|
||||||
<a class="navbar-item" href="/u/{{.CurrentUser.Username}}">My Profile</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="/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>
|
<a class="navbar-item" href="/logout">Log out</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -106,6 +142,7 @@
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</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> -->
|
||||||
|
|
23
web/templates/email/certification_admin.html
Normal file
23
web/templates/email/certification_admin.html
Normal 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}}
|
25
web/templates/email/certification_approved.html
Normal file
25
web/templates/email/certification_approved.html
Normal 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}}
|
32
web/templates/email/certification_rejected.html
Normal file
32
web/templates/email/certification_rejected.html
Normal 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}}
|
76
web/templates/errors/certification_required.html
Normal file
76
web/templates/errors/certification_required.html
Normal 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}}
|
|
@ -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>
|
||||||
|
|
||||||
|
|
166
web/templates/photo/certification.html
Normal file
166
web/templates/photo/certification.html
Normal 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}}
|
|
@ -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-content">
|
<div class="card-image">
|
||||||
|
<figure class="image">
|
||||||
<img src="{{PhotoURL .Filename}}">
|
<img src="{{PhotoURL .Filename}}">
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
{{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"
|
Reference in New Issue
Block a user