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) } // 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.LockSending("cert_rejected", user.Email, config.EmailDebounceDefault); err == nil { 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. if err := mail.LockSending("cert_approved", user.Email, config.EmailDebounceDefault); err == nil { 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, 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 } }) }