website/pkg/controller/photo/certification.go

577 lines
19 KiB
Go
Raw Permalink Normal View History

package photo
import (
"bytes"
"fmt"
"io"
"net/http"
"path/filepath"
"strconv"
"time"
"code.nonshy.com/nonshy/website/pkg/chat"
2022-08-26 04:21:46 +00:00
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/encryption/coldstorage"
2023-11-25 22:28:16 +00:00
"code.nonshy.com/nonshy/website/pkg/geoip"
2022-08-26 04:21:46 +00:00
"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"
2023-11-25 22:28:16 +00:00
"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 = ""
2023-11-25 22:28:16 +00:00
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)
}
// 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)
}
Admin Groups & Permissions Add a permission system for admin users so you can lock down specific admins to a narrower set of features instead of them all having omnipotent powers. * New page: Admin Dashboard -> Admin Permissions Management * Permissions are handled in the form of 'scopes' relevant to each feature or action on the site. Scopes are assigned to Groups, and in turn, admin user accounts are placed in those Groups. * The Superusers group (scope '*') has wildcard permission to all scopes. The permissions dashboard has a create-once action to initialize the Superusers for the first admin who clicks on it, and places that admin in the group. The following are the exhaustive list of permission changes on the site: * Moderator scopes: * Chat room (enter the room with Operator permission) * Forums (can edit or delete user posts on the forum) * Photo Gallery (can see all private/friends-only photos on the site gallery or user profile pages) * Certification photos (with nuanced sub-action permissions) * Approve: has access to the Pending tab to act on incoming pictures * List: can paginate thru past approved/rejected photos * View: can bring up specific user cert photo from their profile * The minimum requirement is Approve or else no cert photo page will load for your admin user. * User Actions (each action individually scoped) * Impersonate * Ban * Delete * Promote to admin * Inner circle whitelist: no longer are admins automatically part of the inner circle unless they have a specialized scope attached. The AdminRequired decorator may also apply scopes on an entire admin route. The following routes have scopes to limit them: * Forum Admin (manage forums and their settings) * Remove from inner circle
2023-08-02 03:39:48 +00:00
// 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 != "" {
Admin Groups & Permissions Add a permission system for admin users so you can lock down specific admins to a narrower set of features instead of them all having omnipotent powers. * New page: Admin Dashboard -> Admin Permissions Management * Permissions are handled in the form of 'scopes' relevant to each feature or action on the site. Scopes are assigned to Groups, and in turn, admin user accounts are placed in those Groups. * The Superusers group (scope '*') has wildcard permission to all scopes. The permissions dashboard has a create-once action to initialize the Superusers for the first admin who clicks on it, and places that admin in the group. The following are the exhaustive list of permission changes on the site: * Moderator scopes: * Chat room (enter the room with Operator permission) * Forums (can edit or delete user posts on the forum) * Photo Gallery (can see all private/friends-only photos on the site gallery or user profile pages) * Certification photos (with nuanced sub-action permissions) * Approve: has access to the Pending tab to act on incoming pictures * List: can paginate thru past approved/rejected photos * View: can bring up specific user cert photo from their profile * The minimum requirement is Approve or else no cert photo page will load for your admin user. * User Actions (each action individually scoped) * Impersonate * Ban * Delete * Promote to admin * Inner circle whitelist: no longer are admins automatically part of the inner circle unless they have a specialized scope attached. The AdminRequired decorator may also apply scopes on an entire admin route. The following routes have scopes to limit them: * Forum Admin (manage forums and their settings) * Remove from inner circle
2023-08-02 03:39:48 +00:00
// 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 {
Admin Groups & Permissions Add a permission system for admin users so you can lock down specific admins to a narrower set of features instead of them all having omnipotent powers. * New page: Admin Dashboard -> Admin Permissions Management * Permissions are handled in the form of 'scopes' relevant to each feature or action on the site. Scopes are assigned to Groups, and in turn, admin user accounts are placed in those Groups. * The Superusers group (scope '*') has wildcard permission to all scopes. The permissions dashboard has a create-once action to initialize the Superusers for the first admin who clicks on it, and places that admin in the group. The following are the exhaustive list of permission changes on the site: * Moderator scopes: * Chat room (enter the room with Operator permission) * Forums (can edit or delete user posts on the forum) * Photo Gallery (can see all private/friends-only photos on the site gallery or user profile pages) * Certification photos (with nuanced sub-action permissions) * Approve: has access to the Pending tab to act on incoming pictures * List: can paginate thru past approved/rejected photos * View: can bring up specific user cert photo from their profile * The minimum requirement is Approve or else no cert photo page will load for your admin user. * User Actions (each action individually scoped) * Impersonate * Ban * Delete * Promote to admin * Inner circle whitelist: no longer are admins automatically part of the inner circle unless they have a specialized scope attached. The AdminRequired decorator may also apply scopes on an entire admin route. The following routes have scopes to limit them: * Forum Admin (manage forums and their settings) * Remove from inner circle
2023-08-02 03:39:48 +00:00
// 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)" {
2023-11-25 22:28:16 +00:00
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)
}
2023-11-25 22:28:16 +00:00
// 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.
2024-09-10 03:59:46 +00:00
if err := mail.LockSending("cert_rejected", user.Email, config.EmailDebounceDefault); err == nil {
2024-09-10 03:52:53 +00:00
if err := mail.Send(mail.Message{
To: user.Email,
Subject: "Your certification photo has been denied",
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)
}
} else {
log.Error("LockSending: cert_rejected e-mail is not sent to %s: one was sent recently", user.Email)
}
}
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-%d.jpg",
user.ID,
user.Username,
time.Now().Unix(),
)
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.
2024-09-10 03:59:46 +00:00
if err := mail.LockSending("cert_approved", user.Email, config.EmailDebounceDefault); err == nil {
2024-09-10 03:52:53 +00:00
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)
}
} else {
log.Error("LockSending: cert_approved e-mail is not sent to %s: one was sent recently", user.Email)
}
// 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,
2022-08-14 05:44:57 +00:00
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)
}
2023-11-25 22:28:16 +00:00
// Map user IDs and GeoIP insights.
var (
userIDs = []uint64{}
ipAddresses = []string{}
)
for _, p := range photos {
userIDs = append(userIDs, p.UserID)
2023-11-25 22:28:16 +00:00
ipAddresses = append(ipAddresses, p.IPAddress)
}
2023-11-25 22:28:16 +00:00
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{}{
2023-11-25 22:28:16 +00:00
"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
}
})
}