f0e69f78da
* Add an Admin Certification Photo workflow where we can request the user to upload a secondary form of ID (government issued photo ID showing their face and date of birth). * An admin rejection option can request secondary photo ID. * It sends a distinct e-mail to the user apart from the regular rejection email * It flags their cert photo as "Secondary Needed" forever: even if the user removes their cert photo and starts from scratch, it will immediately request secondary ID when uploading a new primary photo. * Secondary photos are deleted from the server on both Approve and Reject by the admin account, for user privacy. * If approved, a Secondary Approved=true boolean is stored in the database. This boolean is set to False if the user deletes their cert photo in the future.
563 lines
18 KiB
Go
563 lines
18 KiB
Go
package photo
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
|
|
"code.nonshy.com/nonshy/website/pkg/chat"
|
|
"code.nonshy.com/nonshy/website/pkg/config"
|
|
"code.nonshy.com/nonshy/website/pkg/geoip"
|
|
"code.nonshy.com/nonshy/website/pkg/log"
|
|
"code.nonshy.com/nonshy/website/pkg/mail"
|
|
"code.nonshy.com/nonshy/website/pkg/models"
|
|
"code.nonshy.com/nonshy/website/pkg/photo"
|
|
"code.nonshy.com/nonshy/website/pkg/session"
|
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
|
"code.nonshy.com/nonshy/website/pkg/utility"
|
|
)
|
|
|
|
// 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" {
|
|
|
|
// Primary cert 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)
|
|
}
|
|
cert.Filename = ""
|
|
}
|
|
|
|
// Secondary cert photo
|
|
if cert.SecondaryFilename != "" {
|
|
if err := photo.Delete(cert.SecondaryFilename); err != nil {
|
|
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
|
|
}
|
|
cert.SecondaryFilename = ""
|
|
}
|
|
|
|
cert.Status = models.CertificationPhotoNeeded
|
|
cert.AdminComment = ""
|
|
cert.SecondaryVerified = false
|
|
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)
|
|
}
|
|
|
|
// Log the change.
|
|
models.LogDeleted(currentUser, nil, "certification_photos", currentUser.ID, "Removed their certification photo.", cert)
|
|
|
|
// Kick them from the chat room if they are online.
|
|
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
|
|
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
|
|
}
|
|
|
|
session.Flash(w, r, "Your certification photo has been deleted.")
|
|
templates.Redirect(w, r.URL.Path)
|
|
return
|
|
}
|
|
|
|
// Is it their secondary form of ID being uploaded?
|
|
isSecondary := r.PostFormValue("secondary") == "true"
|
|
|
|
// 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 != "" && !isSecondary {
|
|
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)
|
|
}
|
|
} else if isSecondary && cert.SecondaryFilename != "" {
|
|
if err := photo.Delete(cert.SecondaryFilename); err != nil {
|
|
log.Error("Failed to delete old cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
|
|
}
|
|
}
|
|
|
|
// Update their certification photo.
|
|
cert.Status = models.CertificationPhotoPending
|
|
if isSecondary {
|
|
cert.SecondaryFilename = filename
|
|
cert.SecondaryNeeded = true
|
|
cert.SecondaryVerified = false
|
|
} else {
|
|
cert.Filename = filename
|
|
}
|
|
cert.AdminComment = ""
|
|
cert.IPAddress = utility.IPAddress(r)
|
|
|
|
// Secondary ID workflow: if the user
|
|
// 1. Uploads a regular cert photo
|
|
// 2. An admin marks secondary ID as needed
|
|
// 3. They remove everything and reupload a new cert photo, without a secondary ID attached
|
|
// Then we don't e-mail the admin for approval yet, and move straight to Secondary ID Requested
|
|
// for the user to upload their secondary ID now.
|
|
if cert.Status == models.CertificationPhotoPending && cert.SecondaryNeeded && cert.SecondaryFilename == "" {
|
|
cert.Status = models.CertificationPhotoSecondary
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Kick them from the chat room if they are online.
|
|
if _, err := chat.MaybeDisconnectUser(currentUser); err != nil {
|
|
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", currentUser.Username, currentUser.ID, err)
|
|
}
|
|
|
|
// Notify the admin email to check out this photo.
|
|
if cert.Status == models.CertificationPhotoPending {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Log the change. Note the original IP and GeoIP insights - we once saw a spammer upload
|
|
// their cert photo from Nigeria, and before we could reject it, they removed and reuploaded
|
|
// it from New York using a VPN. If it wasn't seen in real time, this might have slipped by.
|
|
var insights string
|
|
if i, err := geoip.GetRequestInsights(r); err == nil {
|
|
insights = i.String()
|
|
} else {
|
|
insights = "error: " + err.Error()
|
|
}
|
|
models.LogCreated(
|
|
currentUser,
|
|
"certification_photos",
|
|
currentUser.ID,
|
|
fmt.Sprintf(
|
|
"Uploaded a new certification photo.\n\n* From IP address: %s\n* GeoIP insight: %s",
|
|
cert.IPAddress,
|
|
insights,
|
|
),
|
|
)
|
|
|
|
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) {
|
|
// View status
|
|
var view = r.FormValue("view")
|
|
if view == "" {
|
|
view = "pending"
|
|
}
|
|
|
|
// Get the current user.
|
|
currentUser, err := session.CurrentUser(r)
|
|
if err != nil {
|
|
session.FlashError(w, r, "Couldn't get CurrentUser: %s", err)
|
|
}
|
|
|
|
// Scope check based on view.
|
|
switch view {
|
|
case "pending":
|
|
// Scope check.
|
|
if !currentUser.HasAdminScope(config.ScopeCertificationApprove) {
|
|
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeCertificationApprove)
|
|
templates.Redirect(w, "/admin")
|
|
return
|
|
}
|
|
case "approved", "rejected":
|
|
// Scope check.
|
|
if !currentUser.HasAdminScope(config.ScopeCertificationList) {
|
|
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeCertificationList)
|
|
templates.Redirect(w, "/admin")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Short circuit the GET view for username/email search (exact match)
|
|
if username := r.FormValue("username"); username != "" {
|
|
// Scope check.
|
|
if !currentUser.HasAdminScope(config.ScopeCertificationView) {
|
|
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeCertificationView)
|
|
templates.Redirect(w, "/admin")
|
|
return
|
|
}
|
|
|
|
user, err := models.FindUser(username)
|
|
if err != nil {
|
|
session.FlashError(w, r, "Username or email '%s' not found.", username)
|
|
templates.Redirect(w, r.URL.Path)
|
|
return
|
|
}
|
|
|
|
cert, err := models.GetCertificationPhoto(user.ID)
|
|
if err != nil {
|
|
session.FlashError(w, r, "Couldn't get their certification photo: %s", err)
|
|
templates.Redirect(w, r.URL.Path)
|
|
return
|
|
}
|
|
|
|
var vars = map[string]interface{}{
|
|
"View": view,
|
|
"Photos": []*models.CertificationPhoto{cert},
|
|
"UserMap": &models.UserMap{user.ID: user},
|
|
"FoundUser": user,
|
|
}
|
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Making a verdict?
|
|
if r.Method == http.MethodPost {
|
|
// Scope check.
|
|
if !currentUser.HasAdminScope(config.ScopeCertificationApprove) {
|
|
session.FlashError(w, r, "Missing admin scope: %s", config.ScopeCertificationApprove)
|
|
templates.Redirect(w, r.URL.Path)
|
|
return
|
|
}
|
|
|
|
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 comment == "(ignore)" || comment == "(secondary)" {
|
|
cert.AdminComment = ""
|
|
}
|
|
|
|
// With a secondary photo ID? Remove the photo ID immediately.
|
|
if cert.SecondaryFilename != "" {
|
|
// Delete it immediately.
|
|
if err := photo.Delete(cert.SecondaryFilename); err != nil {
|
|
session.FlashError(w, r, "Failed to delete old secondary ID cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
|
|
}
|
|
cert.SecondaryFilename = ""
|
|
cert.SecondaryVerified = false
|
|
}
|
|
|
|
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()
|
|
|
|
// Log the change.
|
|
models.LogEvent(user, currentUser, models.ChangeLogRejected, "certification_photos", user.ID, "Rejected the certification photo with comment: "+comment)
|
|
|
|
// Kick them from the chat room if they are online.
|
|
if _, err := chat.MaybeDisconnectUser(user); err != nil {
|
|
log.Error("chat.MaybeDisconnectUser(%s#%d): %s", user.Username, user.ID, err)
|
|
}
|
|
|
|
// Did we silently ignore it?
|
|
if comment == "(ignore)" {
|
|
session.FlashError(w, r, "The certification photo was ignored with no comment, and will not notify the sender.")
|
|
templates.Redirect(w, r.URL.Path)
|
|
return
|
|
}
|
|
|
|
// Secondary verification required: the user will be asked to upload a blacked-out
|
|
// photo ID to be certified again.
|
|
if comment == "(secondary)" {
|
|
cert.Status = models.CertificationPhotoSecondary
|
|
cert.SecondaryNeeded = true
|
|
cert.SecondaryVerified = false
|
|
if err := cert.Save(); err != nil {
|
|
log.Error("Error saving cert photo: %s", err)
|
|
}
|
|
|
|
// Notify the user about this rejection.
|
|
notif := &models.Notification{
|
|
UserID: user.ID,
|
|
AboutUser: *user,
|
|
Type: models.NotificationCertSecondary,
|
|
Message: "A secondary form of photo ID is requested. Please [click here](/photo/certification) to learn more.",
|
|
}
|
|
if err := models.CreateNotification(notif); err != nil {
|
|
log.Error("Couldn't create rejection notification: %s", err)
|
|
}
|
|
|
|
// Notify the user via email.
|
|
if err := mail.Send(mail.Message{
|
|
To: user.Email,
|
|
Subject: "Regarding your nonshy certification photo",
|
|
Template: "email/certification_secondary.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, "The user will be asked to provide a secondary form of ID.")
|
|
templates.Redirect(w, r.URL.Path)
|
|
return
|
|
}
|
|
|
|
// Notify the user about this rejection.
|
|
notif := &models.Notification{
|
|
UserID: user.ID,
|
|
AboutUser: *user,
|
|
Type: models.NotificationCertRejected,
|
|
Message: comment,
|
|
}
|
|
if err := models.CreateNotification(notif); err != nil {
|
|
log.Error("Couldn't create rejection notification: %s", err)
|
|
}
|
|
|
|
// 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 = ""
|
|
|
|
// With a secondary photo ID?
|
|
if cert.SecondaryFilename != "" {
|
|
// Delete it immediately.
|
|
if err := photo.Delete(cert.SecondaryFilename); err != nil {
|
|
session.FlashError(w, r, "Failed to delete old secondary ID cert photo for %s (%s): %s", currentUser.Username, cert.SecondaryFilename, err)
|
|
}
|
|
cert.SecondaryFilename = ""
|
|
cert.SecondaryVerified = true
|
|
}
|
|
|
|
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 about this approval.
|
|
notif := &models.Notification{
|
|
UserID: user.ID,
|
|
AboutUser: *user,
|
|
Type: models.NotificationCertApproved,
|
|
}
|
|
if err := models.CreateNotification(notif); err != nil {
|
|
log.Error("Couldn't create approval notification: %s", err)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Log the change.
|
|
models.LogEvent(user, currentUser, models.ChangeLogApproved, "certification_photos", user.ID, "Approved the certification photo.")
|
|
|
|
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: config.PageSizeAdminCertification,
|
|
Sort: "updated_at desc",
|
|
}
|
|
pager.ParsePage(r)
|
|
photos, err := models.CertificationPhotosNeedingApproval(models.CertificationPhotoStatus(view), pager)
|
|
if err != nil {
|
|
session.FlashError(w, r, "Couldn't load certification photos from DB: %s", err)
|
|
}
|
|
|
|
// Map user IDs and GeoIP insights.
|
|
var (
|
|
userIDs = []uint64{}
|
|
ipAddresses = []string{}
|
|
)
|
|
for _, p := range photos {
|
|
userIDs = append(userIDs, p.UserID)
|
|
ipAddresses = append(ipAddresses, p.IPAddress)
|
|
}
|
|
insightsMap := geoip.MapInsights(ipAddresses)
|
|
userMap, err := models.MapUsers(currentUser, userIDs)
|
|
if err != nil {
|
|
session.FlashError(w, r, "Couldn't map user IDs: %s", err)
|
|
}
|
|
|
|
var vars = map[string]interface{}{
|
|
"View": view,
|
|
"Photos": photos,
|
|
"UserMap": userMap,
|
|
"InsightsMap": insightsMap,
|
|
"Pager": pager,
|
|
}
|
|
if err := tmpl.Execute(w, r, vars); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
})
|
|
}
|