package arn import ( "errors" "fmt" "os" "path" "sync" "github.com/aerogo/nano" "github.com/akyoto/color" ) // GroupID represents a group ID. type GroupID = ID // Group represents a group of users. type Group struct { Name string `json:"name" editable:"true"` Tagline string `json:"tagline" editable:"true"` Image Image `json:"image"` Description string `json:"description" editable:"true" type:"textarea"` Rules string `json:"rules" editable:"true" type:"textarea"` Tags []string `json:"tags" editable:"true"` Members []*GroupMember `json:"members"` Neighbors []GroupID `json:"neighbors"` // Applications []UserApplication `json:"applications"` // Mixins hasID hasPosts hasCreator hasEditor hasDraft // Mutex membersMutex sync.Mutex // Moved this boolean field to the bottom because the structure consumes less bytes that way Restricted bool `json:"restricted" editable:"true" tooltip:"Restricted groups can only be joined with the founder's permission."` } // Link returns the URI to the group page. func (group *Group) Link() string { return "/group/" + group.ID } // TitleByUser returns the preferred title for the given user. func (group *Group) TitleByUser(user *User) string { if group.Name == "" { return "untitled" } return group.Name } // String is the default text representation of the group. func (group *Group) String() string { return group.TitleByUser(nil) } // FindMember returns the group member by user ID, if available. func (group *Group) FindMember(userID UserID) *GroupMember { group.membersMutex.Lock() defer group.membersMutex.Unlock() for _, member := range group.Members { if member.UserID == userID { return member } } return nil } // HasMember returns true if the user is a member of the group. func (group *Group) HasMember(userID UserID) bool { return group.FindMember(userID) != nil } // Users returns a slice of all users in the group. func (group *Group) Users() []*User { group.membersMutex.Lock() defer group.membersMutex.Unlock() users := make([]*User, len(group.Members)) for index, member := range group.Members { users[index] = member.User() } return users } // TypeName returns the type name. func (group *Group) TypeName() string { return "Group" } // Self returns the object itself. func (group *Group) Self() Loggable { return group } // Publish ... func (group *Group) Publish() error { if len(group.Name) < 2 { return errors.New("Name too short: Should be at least 2 characters") } if len(group.Name) > 35 { return errors.New("Name too long: Should not be more than 35 characters") } if len(group.Tagline) < 4 { return errors.New("Tagline too short: Should be at least 4 characters") } if len(group.Tagline) > 60 { return errors.New("Tagline too long: Should not be more than 60 characters") } if len(group.Description) < 10 { return errors.New("Your group needs a description (at least 10 characters)") } if len(group.Tags) < 1 { return errors.New("At least one tag is required") } if !group.HasImage() { return errors.New("Group image required") } return publish(group) } // Unpublish ... func (group *Group) Unpublish() error { return unpublish(group) } // Join makes the given user join the group. func (group *Group) Join(user *User) error { // Check if the user is already a member member := group.FindMember(user.ID) if member != nil { return errors.New("Already a member of this group") } // Add user to the members list group.membersMutex.Lock() group.Members = append(group.Members, &GroupMember{ UserID: user.ID, Joined: DateTimeUTC(), }) group.membersMutex.Unlock() // Trigger notifications group.OnJoin(user) return nil } // Leave makes the given user leave the group. func (group *Group) Leave(user *User) error { group.membersMutex.Lock() defer group.membersMutex.Unlock() for index, member := range group.Members { if member.UserID == user.ID { if member.UserID == group.CreatedBy { return errors.New("The founder can not leave the group, please contact a staff member") } group.Members = append(group.Members[:index], group.Members[index+1:]...) return nil } } return nil } // OnJoin sends notifications to the creator. func (group *Group) OnJoin(user *User) { go func() { group.Creator().SendNotification(&PushNotification{ Title: fmt.Sprintf(`%s joined your group!`, user.Nick), Message: fmt.Sprintf(`%s has joined your group "%s"`, user.Nick, group.Name), Icon: "https:" + user.AvatarLink("large"), Link: "https://notify.moe" + group.Link() + "/members", Type: NotificationTypeGroupJoin, }) }() } // SendNotification sends a notification to all group members except for the excluded user ID. func (group *Group) SendNotification(notification *PushNotification, excludeUserID UserID) { for _, user := range group.Users() { if user.ID == excludeUserID { continue } user.SendNotification(notification) } } // AverageColor returns the average color of the image. func (group *Group) AverageColor() string { color := group.Image.AverageColor if color.Hue == 0 && color.Saturation == 0 && color.Lightness == 0 { return "" } return color.String() } // ImageLink returns a link to the group image. func (group *Group) ImageLink(size string) string { if !group.HasImage() { return fmt.Sprintf("//%s/images/elements/no-group-image.svg", MediaHost) } extension := ".jpg" if size == "original" { extension = group.Image.Extension } return fmt.Sprintf("//%s/images/groups/%s/%s%s?%v", MediaHost, size, group.ID, extension, group.Image.LastModified) } // DeleteImages deletes all images for the group. func (group *Group) DeleteImages() { if group.Image.Extension == "" { return } // Original err := os.Remove(path.Join(Root, "images/groups/original/", group.ID+group.Image.Extension)) if err != nil { // Don't return the error. // It's too late to stop the process at this point. // Instead, log the error. color.Red(err.Error()) } // Small os.Remove(path.Join(Root, "images/groups/small/", group.ID+".jpg")) os.Remove(path.Join(Root, "images/groups/small/", group.ID+"@2.jpg")) os.Remove(path.Join(Root, "images/groups/small/", group.ID+".webp")) os.Remove(path.Join(Root, "images/groups/small/", group.ID+"@2.webp")) // Large os.Remove(path.Join(Root, "images/groups/large/", group.ID+".jpg")) os.Remove(path.Join(Root, "images/groups/large/", group.ID+"@2.jpg")) os.Remove(path.Join(Root, "images/groups/large/", group.ID+".webp")) os.Remove(path.Join(Root, "images/groups/large/", group.ID+"@2.webp")) } // GetGroup ... func GetGroup(id GroupID) (*Group, error) { obj, err := DB.Get("Group", id) if err != nil { return nil, err } return obj.(*Group), nil } // StreamGroups returns a stream of all groups. func StreamGroups() <-chan *Group { channel := make(chan *Group, nano.ChannelBufferSize) go func() { for obj := range DB.All("Group") { channel <- obj.(*Group) } close(channel) }() return channel } // AllGroups returns a slice of all groups. func AllGroups() []*Group { all := make([]*Group, 0, DB.Collection("Group").Count()) stream := StreamGroups() for obj := range stream { all = append(all, obj) } return all } // FilterGroups filters all groups by a custom function. func FilterGroups(filter func(*Group) bool) []*Group { var filtered []*Group for obj := range DB.All("Group") { realObject := obj.(*Group) if filter(realObject) { filtered = append(filtered, realObject) } } return filtered }