aboutsummaryrefslogtreecommitdiff
path: root/models
diff options
context:
space:
mode:
authorBobby <[email protected]>2025-06-16 10:15:15 +0530
committerBobby <[email protected]>2025-06-16 10:15:15 +0530
commit782be699f797011a6e71b345658762f7e2013636 (patch)
tree0af72643a6188731bd09923143860e9167bba449 /models
parentcfa8164f2468ea5a63b4cce2edb01957846b2b12 (diff)
downloadimageboard-782be699f797011a6e71b345658762f7e2013636.tar.xz
imageboard-782be699f797011a6e71b345658762f7e2013636.zip
added user, image, comments, and tags models with functions
Diffstat (limited to 'models')
-rw-r--r--models/comments.go99
-rw-r--r--models/enums.go120
-rw-r--r--models/image.go303
-rw-r--r--models/tags.go127
-rw-r--r--models/user.go313
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
+}