581 lines
14 KiB
Go

package arn
import (
"errors"
"fmt"
"io/ioutil"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/aerogo/aero"
"github.com/aerogo/http/client"
"github.com/animenotifier/ffxiv"
"github.com/animenotifier/notify.moe/arn/autocorrect"
"github.com/animenotifier/notify.moe/arn/validate"
"github.com/animenotifier/osu"
"github.com/animenotifier/overwatch"
gravatar "github.com/ungerik/go-gravatar"
)
var (
setNickMutex sync.Mutex
setEmailMutex sync.Mutex
)
// UserID represents a user ID.
type UserID = ID
// User is a registered person.
type User struct {
ID UserID `json:"id" primary:"true"`
Nick string `json:"nick" editable:"true"`
FirstName string `json:"firstName" private:"true"`
LastName string `json:"lastName" private:"true"`
Email string `json:"email" editable:"true" private:"true"`
Role string `json:"role"`
Registered string `json:"registered"`
LastLogin string `json:"lastLogin" private:"true"`
LastSeen string `json:"lastSeen" private:"true"`
ProExpires string `json:"proExpires" editable:"true"`
Gender string `json:"gender" editable:"true" private:"true" datalist:"genders"`
Language string `json:"language"`
Introduction string `json:"introduction" editable:"true" type:"textarea"`
Website string `json:"website" editable:"true"`
BirthDay string `json:"birthDay" editable:"true" private:"true"`
IP string `json:"ip" private:"true"`
UserAgent string `json:"agent" private:"true"`
Balance int `json:"balance" private:"true"`
Image Image `json:"image"`
Avatar UserAvatar `json:"avatar" editable:"true"`
Cover UserCover `json:"cover"`
Accounts UserAccounts `json:"accounts" private:"true"`
Browser UserBrowser `json:"browser" private:"true"`
OS UserOS `json:"os" private:"true"`
Location *Location `json:"location" private:"true"`
FollowIDs []UserID `json:"follows"`
hasPosts
eventStreams struct {
sync.Mutex
value []*aero.EventStream
}
}
// NewUser creates an empty user object with a unique ID.
func NewUser() *User {
user := &User{
ID: GenerateID("User"),
// Avoid nil value fields
Location: &Location{},
}
return user
}
// RegisterUser registers a new user in the database and sets up all the required references.
func RegisterUser(user *User) {
user.Registered = DateTimeUTC()
user.LastLogin = user.Registered
user.LastSeen = user.Registered
// Save nick in NickToUser collection
DB.Set("NickToUser", user.Nick, &NickToUser{
Nick: user.Nick,
UserID: user.ID,
})
// Save email in EmailToUser collection
if user.Email != "" {
DB.Set("EmailToUser", user.Email, &EmailToUser{
Email: user.Email,
UserID: user.ID,
})
}
// Create default settings
NewSettings(user).Save()
// Add empty anime list
DB.Set("AnimeList", user.ID, &AnimeList{
UserID: user.ID,
Items: []*AnimeListItem{},
})
// Add empty inventory
NewInventory(user.ID).Save()
// Add draft index
NewDraftIndex(user.ID).Save()
// Add empty push subscriptions
DB.Set("PushSubscriptions", user.ID, &PushSubscriptions{
UserID: user.ID,
Items: []*PushSubscription{},
})
// Add empty notifications list
NewUserNotifications(user.ID).Save()
// Fetch gravatar
if user.Email != "" && !IsDevelopment() {
gravatarURL := gravatar.Url(user.Email) + "?s=" + fmt.Sprint(AvatarMaxSize) + "&d=404&r=pg"
gravatarURL = strings.Replace(gravatarURL, "http://", "https://", 1)
response, err := client.Get(gravatarURL).End()
if err == nil && response.StatusCode() == http.StatusOK {
data := response.Bytes()
err = user.SetImageBytes(data)
if err != nil {
fmt.Println(err)
}
}
}
}
// SendNotification accepts a PushNotification and generates a new Notification object.
// The notification is then sent to all registered push devices.
func (user *User) SendNotification(pushNotification *PushNotification) {
// Don't ever send notifications in development mode
if IsDevelopment() && user.ID != "4J6qpK1ve" {
return
}
// Save notification in database
notification := NewNotification(user.ID, pushNotification)
notification.Save()
userNotifications := user.Notifications()
err := userNotifications.Add(notification.ID)
if err != nil {
fmt.Println(err)
return
}
userNotifications.Save()
// Send push notification
subs := user.PushSubscriptions()
expired := []*PushSubscription{}
for _, sub := range subs.Items {
response, err := sub.SendNotification(pushNotification)
// It is possible to receive a non-nil response with an error, so check the status.
if response != nil && response.StatusCode == http.StatusGone {
expired = append(expired, sub)
}
// Print errors
if err != nil {
fmt.Println(err)
continue
}
// Response body must be closed if no error was returned
defer response.Body.Close()
// Print bad status codes
if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusCreated {
body, _ := ioutil.ReadAll(response.Body)
fmt.Println(response.StatusCode, string(body))
continue
}
sub.LastSuccess = DateTimeUTC()
}
// Remove expired items
if len(expired) > 0 {
for _, sub := range expired {
subs.Remove(sub.ID())
}
}
// Save changes
subs.Save()
// Send email notification
// if IsDevelopment() && user.ID == "4J6qpK1ve" {
// subject := notification.Title
// html := HTMLEmailRenderer.Notification(notification)
// err := SendEmail(user.Email, subject, html)
// if err != nil {
// fmt.Println(err)
// }
// }
// Send an event to the user's open tabs
user.BroadcastEvent(&aero.Event{
Name: "notificationCount",
Data: userNotifications.CountUnseen(),
})
}
// RealName returns the real name of the user.
func (user *User) RealName() string {
if user.LastName == "" {
return user.FirstName
}
if user.FirstName == "" {
return user.LastName
}
return user.FirstName + " " + user.LastName
}
// RegisteredTime returns the time the user registered his account.
func (user *User) RegisteredTime() time.Time {
reg, _ := time.Parse(time.RFC3339, user.Registered)
return reg
}
// LastSeenTime returns the time the user was last seen on the site.
func (user *User) LastSeenTime() time.Time {
lastSeen, _ := time.Parse(time.RFC3339, user.LastSeen)
return lastSeen
}
// AgeInYears returns the user's age in years.
func (user *User) AgeInYears() int {
return AgeInYears(user.BirthDay)
}
// IsActive tells you whether the user is active.
func (user *User) IsActive() bool {
lastSeen, _ := time.Parse(time.RFC3339, user.LastSeen)
twoWeeksAgo := time.Now().Add(-14 * 24 * time.Hour)
if lastSeen.Unix() < twoWeeksAgo.Unix() {
return false
}
if len(user.AnimeList().Items) == 0 {
return false
}
return true
}
// IsPro returns whether the user is a PRO user or not.
func (user *User) IsPro() bool {
if user.ProExpires == "" {
return false
}
return DateTimeUTC() < user.ProExpires
}
// ExtendProDuration extends the PRO account duration by the given duration.
func (user *User) ExtendProDuration(duration time.Duration) {
now := time.Now().UTC()
expires, _ := time.Parse(time.RFC3339, user.ProExpires)
// If the user never had a PRO account yet,
// or if it already expired,
// use the current time as the start time.
if user.ProExpires == "" || now.Unix() > expires.Unix() {
expires = now
}
user.ProExpires = expires.Add(duration).Format(time.RFC3339)
}
// TimeSinceRegistered returns the duration since the user registered his account.
func (user *User) TimeSinceRegistered() time.Duration {
registered, _ := time.Parse(time.RFC3339, user.Registered)
return time.Since(registered)
}
// HasNick returns whether the user has a custom nickname.
func (user *User) HasNick() bool {
return !strings.HasPrefix(user.Nick, "g") && !strings.HasPrefix(user.Nick, "fb") && !strings.HasPrefix(user.Nick, "t") && user.Nick != ""
}
// WebsiteURL adds https:// to the URL.
func (user *User) WebsiteURL() string {
return "https://" + user.WebsiteShortURL()
}
// WebsiteShortURL returns the user website without the protocol.
func (user *User) WebsiteShortURL() string {
return strings.Replace(strings.Replace(user.Website, "https://", "", 1), "http://", "", 1)
}
// Link returns the URI to the user page.
func (user *User) Link() string {
return "/+" + user.Nick
}
// GetID returns the ID.
func (user *User) GetID() string {
return user.ID
}
// HasAvatar tells you whether the user has an avatar or not.
func (user *User) HasAvatar() bool {
return user.Avatar.Extension != ""
}
// AvatarLink returns the URL to the user avatar.
// Expects "small" (50 x 50) or "large" (560 x 560) for the size parameter.
func (user *User) AvatarLink(size string) string {
if user.HasAvatar() {
return fmt.Sprintf("//%s/images/avatars/%s/%s%s?%v", MediaHost, size, user.ID, user.Avatar.Extension, user.Avatar.LastModified)
}
return fmt.Sprintf("//%s/images/elements/no-avatar.svg", MediaHost)
}
// CoverLink ...
func (user *User) CoverLink(size string) string {
if user.Cover.Extension != "" && user.IsPro() {
return fmt.Sprintf("//%s/images/covers/%s/%s%s?%v", MediaHost, size, user.ID, user.Cover.Extension, user.Cover.LastModified)
}
return "/images/elements/default-cover.jpg"
}
// Gravatar returns the URL to the gravatar if an email has been registered.
func (user *User) Gravatar() string {
if user.Email == "" {
return ""
}
return gravatar.SecureUrl(user.Email) + "?s=" + fmt.Sprint(AvatarMaxSize)
}
// EditorScore returns the editor score.
func (user *User) EditorScore() int {
ignoreDifferences := FilterIgnoreAnimeDifferences(func(entry *IgnoreAnimeDifference) bool {
return entry.CreatedBy == user.ID
})
score := len(ignoreDifferences) * IgnoreAnimeDifferenceEditorScore
logEntries := FilterEditLogEntries(func(entry *EditLogEntry) bool {
return entry.UserID == user.ID
})
for _, entry := range logEntries {
score += entry.EditorScore()
}
return score
}
// ActivateItemEffect activates an item in the user inventory by the given item ID.
func (user *User) ActivateItemEffect(itemID string) error {
month := 30 * 24 * time.Hour
switch itemID {
case "pro-account-1":
user.ExtendProDuration(1 * month)
user.Save()
return nil
case "pro-account-3":
user.ExtendProDuration(3 * month)
user.Save()
return nil
case "pro-account-6":
user.ExtendProDuration(6 * month)
user.Save()
return nil
case "pro-account-12":
user.ExtendProDuration(12 * month)
user.Save()
return nil
case "pro-account-24":
user.ExtendProDuration(24 * month)
user.Save()
return nil
default:
return errors.New("Can't activate unknown item: " + itemID)
}
}
// SetNick changes the user's nickname safely.
func (user *User) SetNick(newName string) error {
setNickMutex.Lock()
defer setNickMutex.Unlock()
newName = autocorrect.UserNick(newName)
if !validate.Nick(newName) {
return errors.New("Invalid nickname")
}
if newName == user.Nick {
return nil
}
// Make sure the nickname doesn't exist already
_, err := GetUserByNick(newName)
// If there was no error: the username exists.
// If "not found" is not included in the error message it's a different error type.
if err == nil || !strings.Contains(err.Error(), "not found") {
return errors.New("Username '" + newName + "' is taken already")
}
user.ForceSetNick(newName)
return nil
}
// ForceSetNick forces a nickname overwrite.
func (user *User) ForceSetNick(newName string) {
// Delete old nick reference
DB.Delete("NickToUser", user.Nick)
// Set new nick
user.Nick = newName
DB.Set("NickToUser", user.Nick, &NickToUser{
Nick: user.Nick,
UserID: user.ID,
})
}
// CleanNick only returns the nickname if it was set by user, otherwise empty string.
func (user *User) CleanNick() string {
if user.HasNick() {
return user.Nick
}
return ""
}
// Creator needs to be implemented for the PostParent interface.
func (user *User) Creator() *User {
return user
}
// CreatorID needs to be implemented for the PostParent interface.
func (user *User) CreatorID() UserID {
return user.ID
}
// TitleByUser returns the username.
func (user *User) TitleByUser(viewUser *User) string {
return user.Nick
}
// SetEmail changes the user's email safely.
func (user *User) SetEmail(newEmail string) error {
setEmailMutex.Lock()
defer setEmailMutex.Unlock()
if !validate.Email(newEmail) {
return errors.New("Invalid email address")
}
// Delete old email reference
DB.Delete("EmailToUser", user.Email)
// Set new email
user.Email = newEmail
DB.Set("EmailToUser", user.Email, &EmailToUser{
Email: user.Email,
UserID: user.ID,
})
return nil
}
// HasBasicInfo returns true if the user has a username, an avatar and an introduction.
func (user *User) HasBasicInfo() bool {
return user.HasAvatar() && user.HasNick() && user.Introduction != ""
}
// TypeName returns the type name.
func (user *User) TypeName() string {
return "User"
}
// Self returns the object itself.
func (user *User) Self() Loggable {
return user
}
// RefreshOsuInfo refreshes a user's Osu information.
func (user *User) RefreshOsuInfo() error {
if user.Accounts.Osu.Nick == "" {
return nil
}
osu, err := osu.GetUser(user.Accounts.Osu.Nick)
if err != nil {
return err
}
user.Accounts.Osu.PP, _ = strconv.ParseFloat(osu.PPRaw, 64)
user.Accounts.Osu.Level, _ = strconv.ParseFloat(osu.Level, 64)
user.Accounts.Osu.Accuracy, _ = strconv.ParseFloat(osu.Accuracy, 64)
return nil
}
// RefreshFFXIVInfo refreshes a user's FFXIV information.
func (user *User) RefreshFFXIVInfo() error {
if user.Accounts.FinalFantasyXIV.Nick == "" || user.Accounts.FinalFantasyXIV.Server == "" {
return nil
}
characterID, err := ffxiv.GetCharacterID(user.Accounts.FinalFantasyXIV.Nick, user.Accounts.FinalFantasyXIV.Server)
if err != nil {
return err
}
character, err := ffxiv.GetCharacter(characterID)
if err != nil {
return err
}
user.Accounts.FinalFantasyXIV.Class = character.Class
user.Accounts.FinalFantasyXIV.Level = character.Level
user.Accounts.FinalFantasyXIV.ItemLevel = character.ItemLevel
return nil
}
// RefreshOverwatchInfo refreshes a user's Overwatch information.
func (user *User) RefreshOverwatchInfo() error {
if user.Accounts.Overwatch.BattleTag == "" {
return nil
}
stats, err := overwatch.GetPlayerStats(user.Accounts.Overwatch.BattleTag)
if err != nil {
return err
}
skillRating, tier := stats.HighestSkillRating()
// Only show career highest skill rating
if skillRating > user.Accounts.Overwatch.SkillRating {
user.Accounts.Overwatch.SkillRating = skillRating
user.Accounts.Overwatch.Tier = tier
}
return nil
}