diff options
| author | Bobby <[email protected]> | 2025-06-16 10:15:15 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2025-06-16 10:15:15 +0530 |
| commit | 782be699f797011a6e71b345658762f7e2013636 (patch) | |
| tree | 0af72643a6188731bd09923143860e9167bba449 | |
| parent | cfa8164f2468ea5a63b4cce2edb01957846b2b12 (diff) | |
| download | imageboard-782be699f797011a6e71b345658762f7e2013636.tar.xz imageboard-782be699f797011a6e71b345658762f7e2013636.zip | |
added user, image, comments, and tags models with functions
| -rw-r--r-- | models/comments.go | 99 | ||||
| -rw-r--r-- | models/enums.go | 120 | ||||
| -rw-r--r-- | models/image.go | 303 | ||||
| -rw-r--r-- | models/tags.go | 127 | ||||
| -rw-r--r-- | models/user.go | 313 |
5 files changed, 962 insertions, 0 deletions
diff --git a/models/comments.go b/models/comments.go new file mode 100644 index 0000000..4533dea --- /dev/null +++ b/models/comments.go @@ -0,0 +1,99 @@ +package models + +import ( + "fmt" + "strings" + + "gorm.io/gorm" +) + +type Comment struct { + gorm.Model + Body string `gorm:"not null;type:text" json:"body"` + UserID uint `gorm:"not null;index" json:"-"` + User User `gorm:"foreignKey:UserID" json:"user"` + ImageID uint `gorm:"not null;index" json:"-"` + Image Image `gorm:"foreignKey:ImageID" json:"image"` + ParentID *uint `gorm:"index" json:"-"` + Parent *Comment `gorm:"foreignKey:ParentID" json:"parent,omitempty"` + Replies []Comment `gorm:"foreignKey:ParentID" json:"replies,omitempty"` + IsDeleted bool `gorm:"not null;default:false" json:"is_deleted"` + IsSticky bool `gorm:"not null;default:false" json:"is_sticky"` +} + +func (c *Comment) BeforeCreate(tx *gorm.DB) error { + c.Body = strings.TrimSpace(c.Body) + if c.Body == "" { + return fmt.Errorf("comment body cannot be empty") + } + + if len(c.Body) > 10000 { + return fmt.Errorf("comment body must not exceed 10,000 characters") + } + + return nil +} + +func (c *Comment) BeforeUpdate(tx *gorm.DB) error { + c.Body = strings.TrimSpace(c.Body) + + if c.Body == "" { + return fmt.Errorf("comment body cannot be empty") + } + + if len(c.Body) > 10000 { + return fmt.Errorf("comment body cannot exceed 10000 characters") + } + + return nil +} + +func (c *Comment) AfterCreate(tx *gorm.DB) error { + return tx.Model(&Image{}).Where("id = ?", c.ImageID).UpdateColumn("comment_count", gorm.Expr("comment_count + ?", 1)).Error +} + +func (c *Comment) CanEdit(user *User) bool { + if user == nil || !user.IsActive() { + return false + } + + if c.UserID == user.ID { + return true + } + + return user.CanEditPosts() +} + +func (c *Comment) CanDelete(user *User) bool { + if user == nil || !user.IsActive() { + return false + } + + if c.UserID == user.ID { + return true + } + + return user.CanDeletePosts() +} + +func (c *Comment) DeleteComment(tx *gorm.DB) error { + if c.IsDeleted { + return fmt.Errorf("comment is already deleted") + } + + c.IsDeleted = true + if err := tx.Save(c).Error; err != nil { + return err + } + + return tx.Model(&Image{}).Where("id = ?", c.ImageID).UpdateColumn("comment_count", gorm.Expr("comment_count - ?", 1)).Error +} + +func (c *Comment) GetReplies(tx *gorm.DB) ([]Comment, error) { + var replies []Comment + err := tx.Where("parent_id = ? AND is_deleted = ?", c.ID, false). + Preload("User"). + Order("created_at ASC"). + Find(&replies).Error + return replies, err +} diff --git a/models/enums.go b/models/enums.go new file mode 100644 index 0000000..3334141 --- /dev/null +++ b/models/enums.go @@ -0,0 +1,120 @@ +package models + +type UserLevel int + +const ( + UserLevelMember UserLevel = iota + UserLevelContributor + UserLevelJanitor + UserLevelModerator + UserLevelAdmin + UserLevelSuperAdmin +) + +func (l UserLevel) String() string { + switch l { + case UserLevelMember: + return "Member" + case UserLevelContributor: + return "Contributor" + case UserLevelJanitor: + return "Janitor" + case UserLevelModerator: + return "Moderator" + case UserLevelAdmin: + return "Admin" + default: + return "Unknown" + } +} + +func (l UserLevel) Color() string { + switch l { + case UserLevelMember: + return "#8B9DC3" // Soft periwinkle blue + case UserLevelContributor: + return "#7FCDAE" // Mint green + case UserLevelJanitor: + return "#9BB5FF" // Light electric blue + case UserLevelModerator: + return "#FF9F9B" // Coral pink + case UserLevelAdmin: + return "#C39BD3" // Lavender purple + case UserLevelSuperAdmin: + return "#FFD93D" // Electric yellow + default: + return "#B0B0B0" // Neutral gray + } +} + +type Rating string + +const ( + RatingSafe Rating = "Safe" + RatingQuestionable Rating = "Questionable" + RatingSensitive Rating = "Sensitive" + RatingExplicit Rating = "Explicit" +) + +type ImageContentType string + +const ( + ImageContentTypeJPEG ImageContentType = "image/jpeg" + ImageContentTypePNG ImageContentType = "image/png" + ImageContentTypeGIF ImageContentType = "image/gif" + ImageContentTypeWebP ImageContentType = "image/webp" + ImageContentTypeAVIF ImageContentType = "image/avif" + ImageContentTypeSVG ImageContentType = "image/svg+xml" + ImageContentTypeBMP ImageContentType = "image/bmp" + ImageContentTypeTIFF ImageContentType = "image/tiff" + ImageContentTypeICO ImageContentType = "image/x-icon" + ImageContentTypeHEIC ImageContentType = "image/heic" + ImageContentTypeHEIF ImageContentType = "image/heif" + ImageContentTypeUnknown ImageContentType = "application/octet-stream" +) + +type ImageSizeType string + +const ( + ImageSizeTypeIcon ImageSizeType = "icon" + ImageSizeTypeThumbnail ImageSizeType = "thumbnail" + ImageSizeTypeSmall ImageSizeType = "small" + ImageSizeTypeMedium ImageSizeType = "medium" + ImageSizeTypeLarge ImageSizeType = "large" + ImageSizeTypeOriginal ImageSizeType = "original" +) + +type TagType string + +const ( + TagTypeGeneral TagType = "general" + TagTypeArtist TagType = "artist" + TagTypeCopyright TagType = "copyright" + TagTypeCharacter TagType = "character" + TagTypeMeta TagType = "meta" +) + +func (t TagType) Color() string { + switch t { + case TagTypeGeneral: + return "#4ECDC4" // Turquoise cyan + case TagTypeArtist: + return "#FF6B9D" // Hot pink + case TagTypeCopyright: + return "#A8E6CF" // Mint green + case TagTypeCharacter: + return "#FFB347" // Peach orange + case TagTypeMeta: + return "#DDA0DD" // Plum purple + default: + return "#E6E6FA" // Light lavender + } +} + +type EmailTokenType string + +const ( + EmailTokenTypeVerification EmailTokenType = "verification" + EmailTokenTypePasswordReset EmailTokenType = "password_reset" + EmailTokenTypeChangeEmail EmailTokenType = "change_email" +) diff --git a/models/image.go b/models/image.go new file mode 100644 index 0000000..fef3bf8 --- /dev/null +++ b/models/image.go @@ -0,0 +1,303 @@ +package models + +import ( + "fmt" + "imageboard/config" + "imageboard/utils/math" + "strings" + + "gorm.io/gorm" +) + +type ImageSize struct { + gorm.Model + ImageID uint `gorm:"not null;index" json:"-"` + Image Image `gorm:"foreignKey:ImageID" json:"image"` + SizeType ImageSizeType `gorm:"not null;size:50" json:"size_type"` + Width int `gorm:"not null" json:"width"` + Height int `gorm:"not null" json:"height"` + FileSize int64 `gorm:"not null" json:"file_size"` +} + +func (s *ImageSize) BeforeCreate(tx *gorm.DB) error { + if s.Width <= 0 || s.Height <= 0 { + return fmt.Errorf("image dimensions must be greater than zero") + } + + if s.FileSize <= 0 { + return fmt.Errorf("file size must be greater than zero") + } + + return nil +} + +func (s *ImageSize) GetURL() string { + if config.S3.PublicURL == "" { + return "" + } + return fmt.Sprintf("%s/%s/%s/%s/%s", config.S3.PublicURL, config.S3.BucketName, config.S3.FolderPath, s.SizeType, s.Image.FileName) +} + +func (s *ImageSize) GetAspectRatio() float64 { + if s.Height == 0 { + return 0 + } + return float64(s.Width) / float64(s.Height) +} + +func (s *ImageSize) GetDimensions() string { + return fmt.Sprintf("%dx%d", s.Width, s.Height) +} + +func (s *ImageSize) GetFileSizeFormatted() string { + const unit = 1024 + if s.FileSize < unit { + return fmt.Sprintf("%d B", s.FileSize) + } + div, exp := int64(unit), 0 + for n := s.FileSize / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + + return fmt.Sprintf("%.2f %sB", float64(s.FileSize)/float64(div), "KMGTPE"[exp:exp+1]) +} + +type Image struct { + gorm.Model + FileName string `gorm:"not null;size:255" json:"file_name"` + OriginalName string `gorm:"not null;size:255" json:"original_name"` + ContentType ImageContentType `gorm:"not null;size:100" json:"content_type"` + MD5Hash string `gorm:"not null;size:32" json:"md5_hash"` + Title string `gorm:"default:'';size:255" json:"title"` + Description string `gorm:"default:'';type:text" json:"description"` + SourceURL string `gorm:"default:'';size:500" json:"source_url"` + Rating Rating `gorm:"not null;default:'safe';size:10" json:"rating"` + IsApproved bool `gorm:"not null;default:true" json:"is_approved"` + IsDeleted bool `gorm:"not null;default:false" json:"is_deleted"` + ThreadLocked bool `gorm:"not null;default:false" json:"thread_locked"` + UploaderID uint `gorm:"not null;index" json:"-"` + Uploader User `gorm:"foreignKey:UploaderID" json:"uploader"` + ApproverID *uint `gorm:"index" json:"-"` + Approver *User `gorm:"foreignKey:ApproverID" json:"approver,omitempty"` + RelatedImages []Image `gorm:"many2many:image_relationships;joinForeignKey:image_id;joinReferences:related_image_id" json:"related_images,omitempty"` + ViewCount int64 `gorm:"not null;default:0" json:"view_count"` + FavouriteCount int64 `gorm:"not null;default:0" json:"favorite_count"` + CommentCount int64 `gorm:"not null;default:0" json:"comment_count"` + Sizes []ImageSize `gorm:"foreignKey:ImageID" json:"sizes,omitempty"` + Tags []Tag `gorm:"many2many:image_tags" json:"tags,omitempty"` + FavoritedBy []User `gorm:"many2many:user_favorites" json:"favorited_by,omitempty"` + Comments []Comment `gorm:"foreignKey:ImageID" json:"comments,omitempty"` +} + +func (i *Image) BeforeCreate(tx *gorm.DB) error { + i.FileName = strings.TrimSpace(i.FileName) + i.OriginalName = strings.TrimSpace(i.OriginalName) + i.Title = strings.TrimSpace(i.Title) + i.Description = strings.TrimSpace(i.Description) + + if i.FileName == "" { + return fmt.Errorf("file name cannot be empty") + } + + if len(i.MD5Hash) != 32 { + return fmt.Errorf("MD5 hash must be exactly 32 characters long") + } + + return nil +} + +func (i *Image) BeforeDelete(tx *gorm.DB) error { + return tx.Exec(`UPDATE tags SET count = count - 1 WHERE id IN ( + SELECT tag_id FROM image_tags WHERE image_id = ? + ) AND count > 0`, i.ID).Error +} + +func (i *Image) GetURL(sizeType ImageSizeType) string { + for _, size := range i.Sizes { + if size.SizeType == sizeType { + return size.GetURL() + } + } + + return "" +} + +func (i *Image) GetSize(sizeType ImageSizeType) *ImageSize { + for _, size := range i.Sizes { + if size.SizeType == sizeType { + return &size + } + } + return nil +} + +func (i *Image) GetOriginalDimensions() string { + if fullSize := i.GetSize(ImageSizeTypeOriginal); fullSize != nil { + return fullSize.GetDimensions() + } + return "Unknown" +} + +func (i *Image) GetAspectRatio() string { + if fullSize := i.GetSize(ImageSizeTypeOriginal); fullSize != nil { + if fullSize.Height == 0 { + return "Unknown" + } + + width := fullSize.Width + height := fullSize.Height + + divisor := math.GCD(width, height) + simplifiedWidth := width / divisor + simplifiedHeight := height / divisor + + return fmt.Sprintf("%d:%d", simplifiedWidth, simplifiedHeight) + } + return "Unknown" +} + +func (i *Image) AddSize(tx *gorm.DB, sizeType ImageSizeType, width, height int, fileSize int64) (*ImageSize, error) { + if width <= 0 || height <= 0 { + return nil, fmt.Errorf("image dimensions must be greater than zero") + } + + if fileSize <= 0 { + return nil, fmt.Errorf("file size must be greater than zero") + } + + size := &ImageSize{ + ImageID: i.ID, + SizeType: sizeType, + Width: width, + Height: height, + FileSize: fileSize, + } + + if err := tx.Create(size).Error; err != nil { + return nil, fmt.Errorf("failed to create image size: %v", err) + } + + i.Sizes = append(i.Sizes, *size) + return size, nil +} + +func (i *Image) AddRelatedImage(tx *gorm.DB, relatedImage *Image) error { + if relatedImage.ID == 0 { + return fmt.Errorf("related image must be saved before adding relationship") + } + + if relatedImage.IsDeleted { + return fmt.Errorf("cannot add deleted image as related image") + } + + if i.ID == relatedImage.ID { + return fmt.Errorf("cannot relate an image to itself") + } + + // If the relationship already exists, do nothing + var count int64 + if err := tx.Table("image_relationships").Where("image_id = ? AND related_image_id = ?", i.ID, relatedImage.ID).Count(&count).Error; err != nil { + return fmt.Errorf("failed to check existing relationship: %v", err) + } + + if count > 0 { + return nil + } + + // Create bi-directional relationship + if err := tx.Model(&i).Association("RelatedImages").Append(relatedImage); err != nil { + return fmt.Errorf("failed to add related image: %v", err) + } + if err := tx.Model(&relatedImage).Association("RelatedImages").Append(i); err != nil { + return fmt.Errorf("failed to add related image: %v", err) + } + + return nil +} + +func (i *Image) RemoveRelatedImage(tx *gorm.DB, relatedImage *Image) error { + if relatedImage.ID == 0 { + return fmt.Errorf("related image must be saved before removing relationship") + } + + if i.ID == relatedImage.ID { + return fmt.Errorf("cannot remove self from related images") + } + + // Remove bi-directional relationship + if err := tx.Model(&i).Association("RelatedImages").Delete(relatedImage); err != nil { + return fmt.Errorf("failed to remove related image: %v", err) + } + if err := tx.Model(&relatedImage).Association("RelatedImages").Delete(i); err != nil { + return fmt.Errorf("failed to remove related image: %v", err) + } + + return nil +} + +func (i *Image) GetRelatedImages(tx *gorm.DB) ([]Image, error) { + var relatedImages []Image + if err := tx.Model(&i).Association("RelatedImages").Find(&relatedImages); err != nil { + return nil, fmt.Errorf("failed to get related images: %v", err) + } + return relatedImages, nil +} + +func (i *Image) AddTag(tx *gorm.DB, tag *Tag) error { + if i.IsDeleted || tag.IsDeleted { + return fmt.Errorf("cannot add tag to deleted image or add deleted tag") + } + + // Check if already associated + var count int64 + if err := tx.Table("image_tags").Where("image_id = ? AND tag_id = ?", i.ID, tag.ID).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return nil + } + + // Add association + if err := tx.Model(i).Association("Tags").Append(tag); err != nil { + return err + } + + // Update tag count + return tx.Model(tag).UpdateColumn("count", gorm.Expr("count + ?", 1)).Error +} + +func (i *Image) RemoveTag(tx *gorm.DB, tag *Tag) error { + // Check if associated + var count int64 + if err := tx.Table("image_tags").Where("image_id = ? AND tag_id = ?", i.ID, tag.ID).Count(&count).Error; err != nil { + return err + } + if count == 0 { + return nil // Not associated + } + + // Remove association + if err := tx.Model(i).Association("Tags").Delete(tag); err != nil { + return err + } + + // Update tag count + return tx.Model(tag).UpdateColumn("count", gorm.Expr("GREATEST(count - ?, 0)", 1)).Error +} + +func (i *Image) GetTags(tx *gorm.DB) ([]Tag, error) { + var tags []Tag + if err := tx.Model(i).Association("Tags").Find(&tags); err != nil { + return nil, fmt.Errorf("failed to get image tags: %v", err) + } + return tags, nil +} + +func (i *Image) DeleteImage(tx *gorm.DB) error { + if i.IsDeleted { + return fmt.Errorf("image is already deleted") + } + i.IsDeleted = true + return tx.Save(i).Error +} diff --git a/models/tags.go b/models/tags.go new file mode 100644 index 0000000..61f7013 --- /dev/null +++ b/models/tags.go @@ -0,0 +1,127 @@ +package models + +import ( + "fmt" + "imageboard/utils/validators" + "strings" + + "gorm.io/gorm" +) + +type Tag struct { + gorm.Model + Name string `gorm:"not null;uniqueIndex;size:100" json:"name"` + Type TagType `gorm:"not null;default:'general';size:20" json:"type"` + Description string `gorm:"default:'';type:text" json:"description"` + Count int `gorm:"not null;default:0" json:"count"` + IsDeleted bool `gorm:"not null;default:false" json:"is_deleted"` + ParentID *uint `gorm:"index" json:"-"` + Parent *Tag `gorm:"foreignKey:ParentID" json:"parent,omitempty"` + Children []Tag `gorm:"foreignKey:ParentID" json:"children,omitempty"` + Images []Image `gorm:"many2many:image_tags" json:"images,omitempty"` +} + +func (t *Tag) BeforeCreate(tx *gorm.DB) error { + t.Name = strings.TrimSpace(strings.ToLower(t.Name)) + t.Description = strings.TrimSpace(t.Description) + + if t.Name == "" { + return fmt.Errorf("tag name cannot be empty") + } + + if len(t.Name) < 2 || len(t.Name) > 100 { + return fmt.Errorf("tag name must be between 2 and 100 characters") + } + + if !validators.IsValidTagName(t.Name) { + return fmt.Errorf("tag name can only contain letters, numbers, and underscores") + } + + var existingTag Tag + if err := tx.Where("name = ?", t.Name).First(&existingTag).Error; err == nil { + return fmt.Errorf("tag name '%s' is already taken", t.Name) + } + + return nil +} + +func (t *Tag) BeforeUpdate(tx *gorm.DB) error { + t.Name = strings.TrimSpace(strings.ToLower(t.Name)) + t.Description = strings.TrimSpace(t.Description) + return nil +} + +func (t *Tag) GetFullPath() string { + if t.Parent == nil { + return t.Name + } + return t.Parent.GetFullPath() + ":" + t.Name +} + +func SearchTags(tx *gorm.DB, query string, limit int) ([]Tag, error) { + var tags []Tag + searchPattern := "%" + strings.TrimSpace(strings.ToLower(query)) + "%" + + err := tx.Where("name LIKE ? AND is_deleted = ?", searchPattern, false). + Order("count DESC, name ASC").Limit(limit).Find(&tags).Error + + return tags, err +} + +func SearchTagsExcluding(tx *gorm.DB, query string, imageID uint, limit int) ([]Tag, error) { + var tags []Tag + searchPattern := "%" + strings.TrimSpace(strings.ToLower(query)) + "%" + + err := tx.Where("name LIKE ? AND is_deleted = ? AND id NOT IN (?)", + searchPattern, false, + tx.Table("image_tags").Select("tag_id").Where("image_id = ?", imageID)). + Order("count DESC, name ASC").Limit(limit).Find(&tags).Error + + return tags, err +} + +func FindOrCreateTag(tx *gorm.DB, name string, tagType TagType) (*Tag, error) { + name = strings.TrimSpace(strings.ToLower(name)) + + // First check for active tag + var tag Tag + if err := tx.Where("name = ? AND is_deleted = ?", name, false).First(&tag).Error; err == nil { + return &tag, nil + } + + // Check for deleted tag and restore it + if err := tx.Where("name = ? AND is_deleted = ?", name, true).First(&tag).Error; err == nil { + tag.IsDeleted = false + tag.Type = tagType // Update type in case it changed + if err := tx.Save(&tag).Error; err != nil { + return nil, fmt.Errorf("failed to restore tag: %v", err) + } + return &tag, nil + } + + // Create new tag + tag = Tag{ + Name: name, + Type: tagType, + } + + if err := tx.Create(&tag).Error; err != nil { + return nil, err + } + + return &tag, nil +} + +func (t *Tag) DeleteTag(tx *gorm.DB) error { + if t.IsDeleted { + return fmt.Errorf("tag is already deleted") + } + + if err := tx.Model(t).Association("Images").Clear(); err != nil { + return fmt.Errorf("failed to clear image associations: %v", err) + } + + t.IsDeleted = true + t.Count = 0 + return tx.Save(t).Error +} diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..5607e68 --- /dev/null +++ b/models/user.go @@ -0,0 +1,313 @@ +package models + +import ( + "fmt" + "imageboard/config" + "imageboard/utils/validators" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type User struct { + gorm.Model + Username string `gorm:"uniqueIndex;not null;size:255" json:"username"` + Email string `gorm:"not null;size:255" json:"email"` + Password string `gorm:"not null;size:255" json:"-"` + Level UserLevel `gorm:"not null;default:0" json:"level"` + EmailVerified bool `gorm:"not null;default:false" json:"email_verified"` + Bio string `gorm:"default:'';size:500" json:"bio"` + AvatarURL string `gorm:"default:'';size:255" json:"avatar_url"` + WebsiteURL string `gorm:"default:'';size:255" json:"website_url"` + Location string `gorm:"default:'';size:255" json:"location"` + Timezone string `gorm:"default:'UTC';size:50" json:"timezone"` + AccountDisabled bool `gorm:"not null;default:false" json:"-"` + AccountBanned bool `gorm:"not null;default:false" json:"-"` + PostsRequireApproval bool `gorm:"not null;default:false" json:"-"` + IsDeleted bool `gorm:"not null;default:false" json:"-"` + LastLoginAt *time.Time `gorm:"default:null" json:"last_login_at"` + LastActivityAt *time.Time `gorm:"default:null" json:"last_activity_at"` + Images []Image `gorm:"foreignKey:UploaderID" json:"images,omitempty"` +} + +func (u *User) BeforeCreate(tx *gorm.DB) error { + u.Username = strings.TrimSpace(u.Username) + u.Email = strings.TrimSpace(strings.ToLower(u.Email)) + + if u.Username == "" { + return fmt.Errorf("username cannot be empty") + } + + if u.Email == "" { + return fmt.Errorf("email cannot be empty") + } + + if len(u.Username) < 3 { + return fmt.Errorf("username must be at least 3 characters long") + } + + if len(u.Username) > 72 { + return fmt.Errorf("username must not exceed 72 characters") + } + + if !validators.IsValidUsername(u.Username) { + return fmt.Errorf("username can only contain letters, numbers, underscores, and hyphens") + } + + if validators.IsReservedUsername(u.Username) { + return fmt.Errorf("username '%s' is reserved and cannot be used", u.Username) + } + + if !validators.IsValidEmail(u.Email) { + return fmt.Errorf("invalid email format") + } + + // Check if username is already taken + var existingUser User + if err := tx.Where("username = ?", u.Username).First(&existingUser).Error; err == nil { + return fmt.Errorf("username '%s' is already taken", u.Username) + } + + var userCount int64 + if err := tx.Model(&User{}).Where("is_deleted = ?", false).Count(&userCount).Error; err != nil { + return err + } + + if userCount == 0 { + u.Level = UserLevelSuperAdmin // First user becomes Super Admin + } + + return nil +} + +func (u *User) BeforeUpdate(tx *gorm.DB) error { + u.Username = strings.TrimSpace(u.Username) + u.Email = strings.TrimSpace(strings.ToLower(u.Email)) + + return nil +} + +func (u *User) SetPassword(password string) error { + if len(password) < config.Server.MinPasswordLength { + return fmt.Errorf("password must be at least %d characters long", config.Server.MinPasswordLength) + } + + if len(password) > 255 { + return fmt.Errorf("password must not exceed 255 characters") + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + + u.Password = string(hashedPassword) + return nil +} + +func (u *User) CheckPassword(password string) bool { + if u.IsDeleted { + return false + } + + err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) + return err == nil +} + +func (u *User) IsActive() bool { + return !u.IsDeleted && !u.AccountDisabled && !u.AccountBanned +} + +func (u *User) CanLogin() bool { + return u.IsActive() && (u.EmailVerified || u.IsAdmin()) +} + +func (u *User) IsAdmin() bool { + return u.Level >= UserLevelAdmin +} + +func (u *User) IsModerator() bool { + return u.IsActive() && u.Level >= UserLevelModerator +} + +func (u *User) IsJanitor() bool { + return u.IsActive() && u.Level >= UserLevelJanitor +} + +func (u *User) IsContributor() bool { + return u.IsActive() && u.Level >= UserLevelContributor +} + +func (u *User) IsMember() bool { + return u.IsActive() && u.Level >= UserLevelMember +} + +func (u *User) CanUpload() bool { + return u.IsActive() && (u.EmailVerified || u.IsAdmin()) +} + +func (u *User) CanComment() bool { + return u.IsActive() && (u.EmailVerified || u.IsAdmin()) +} + +func (u *User) CanMessage() bool { + return u.IsActive() && (u.EmailVerified || u.IsAdmin()) +} + +func (u *User) CanCreateTags() bool { + return u.IsContributor() +} + +func (u *User) CanEditTags() bool { + return u.IsJanitor() +} + +func (u *User) CanEditPosts() bool { + return u.IsJanitor() +} + +func (u *User) CanDeletePosts() bool { + return u.IsModerator() +} + +func (u *User) CanApprovePosts() bool { + return u.IsJanitor() +} + +func (u *User) CanEditUser(targetUser *User) bool { + if u.ID == targetUser.ID { + return true + } + + if targetUser.IsDeleted { + return false + } + + return (u.IsAdmin() || u.IsModerator()) && targetUser.Level < u.Level +} + +func (u *User) CanPromoteUser(targetUser *User, newLevel UserLevel) bool { + if u.ID == targetUser.ID || targetUser.IsDeleted { + return false + } + + if u.Level <= UserLevelContributor { + return false + } + + return newLevel > UserLevelMember && newLevel <= u.Level && newLevel <= UserLevelAdmin +} + +func (u *User) CanDemoteUser(targetUser *User, newLevel UserLevel) bool { + if u.ID == targetUser.ID || targetUser.IsDeleted { + return false + } + + if u.Level <= UserLevelContributor { + return false + } + + return newLevel >= UserLevelMember && newLevel < u.Level && newLevel <= UserLevelAdmin +} + +func (u *User) CanDisableUser(targetUser *User) bool { + if u.ID == targetUser.ID || targetUser.IsDeleted { + return false + } + + return (u.IsJanitor() || u.IsModerator() || u.IsAdmin()) && targetUser.Level < u.Level +} + +func (u *User) CanBanUser(targetUser *User) bool { + if u.ID == targetUser.ID || targetUser.IsDeleted { + return false + } + + return (u.IsModerator() || u.IsAdmin()) && targetUser.Level < u.Level +} + +func (u *User) CanDeleteUser(targetUser *User) bool { + if targetUser.IsDeleted { + return false + } + + if u.ID == targetUser.ID { + return true // Users can delete their own account + } + + if u.Level <= UserLevelContributor { + return false + } + + return (u.IsAdmin() || u.IsModerator()) && targetUser.Level < u.Level +} + +func (u *User) CanMakeUserPostsRequireApproval(targetUser *User) bool { + if targetUser.IsDeleted { + return false + } + + return (u.IsJanitor() || u.IsModerator() || u.IsAdmin()) && targetUser.Level < u.Level +} + +func (u *User) GetDailyPostLimit() int { + switch u.Level { + case UserLevelMember: + return 10 + case UserLevelContributor: + return 25 + default: + return -1 // No limit for Janitors, Moderators, and Admins + } +} + +func (u *User) GetDailyRemainingUploadLimit(tx *gorm.DB) (int64, error) { + totalAllowed := u.GetDailyPostLimit() + if totalAllowed == -1 { + return -1, nil + } + + today := time.Now().Truncate(24 * time.Hour) + tomorrow := today.Add(24 * time.Hour) + + var count int64 + err := tx.Model(&Image{}).Where("uploader_id = ? AND created_at >= ? AND created_at < ? AND is_deleted = ?", + u.ID, today, tomorrow, false).Count(&count).Error + + return int64(totalAllowed) - count, err +} + +func (u *User) CanUploadToday(tx *gorm.DB) (bool, error) { + remaining, err := u.GetDailyRemainingUploadLimit(tx) + if err != nil { + return false, err + } + return remaining != 0, nil +} + +func (u *User) UpdateLastUserActivity(tx *gorm.DB) error { + now := time.Now() + u.LastActivityAt = &now + return tx.Model(u).Update("last_activity_at", now).Error +} + +func (u *User) UpdateLastUserLogin(tx *gorm.DB) error { + now := time.Now() + u.LastLoginAt = &now + u.LastActivityAt = &now + return tx.Model(u).Updates(map[string]interface{}{ + "last_login_at": now, + "last_activity_at": now, + }).Error +} + +func (u *User) DeleteUser(tx *gorm.DB) error { + if u.IsDeleted { + return fmt.Errorf("user is already deleted") + } + + u.IsDeleted = true + return tx.Save(u).Error +} |
