584 lines
19 KiB
Go
584 lines
19 KiB
Go
package photo
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"time"
|
|
|
|
"code.nonshy.com/nonshy/website/pkg/chat"
|
|
"code.nonshy.com/nonshy/website/pkg/config"
|
|
"code.nonshy.com/nonshy/website/pkg/encryption/coldstorage"
|
|
"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 != "" {
|
|
// Move the original photo into cold storage.
|
|
coldStorageFilename := fmt.Sprintf(
|
|
"photoID-%d-%s-%s.jpg",
|
|
user.ID,
|
|
user.Username,
|
|
time.Now().Format(time.RFC3339Nano),
|
|
)
|
|
if err := coldstorage.FileToColdStorage(
|
|
photo.DiskPath(cert.SecondaryFilename),
|
|
coldStorageFilename,
|
|
config.Current.Encryption.ColdStorageRSAPublicKey,
|
|
); err != nil {
|
|
session.FlashError(w, r, "Failed to move to cold storage: %s", err)
|
|
templates.Redirect(w, r.URL.Path)
|
|
return
|
|
} else {
|
|
session.Flash(w, r, "Note: the secondary photo ID was encrypted to cold storage @ %s", coldStorageFilename)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
})
|
|
}
|