diff options
| author | Bobby <[email protected]> | 2025-07-17 10:47:08 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2025-07-17 10:47:08 +0530 |
| commit | b0ba363696a758a8d0637107bd29a0a9ac1382d4 (patch) | |
| tree | f11acd0ebc5a4b3d633a6a596deee92b575f8f1c | |
| parent | 94cca506f6d1461bf38afa5b0e38d778391b8d39 (diff) | |
| download | imageboard-b0ba363696a758a8d0637107bd29a0a9ac1382d4.tar.xz imageboard-b0ba363696a758a8d0637107bd29a0a9ac1382d4.zip | |
refactor and fake upload
| -rw-r--r-- | config/enums.go (renamed from models/enums.go) | 2 | ||||
| -rw-r--r-- | config/types.go | 4 | ||||
| -rw-r--r-- | controllers/account.go | 3 | ||||
| -rw-r--r-- | controllers/posts.go | 67 | ||||
| -rw-r--r-- | database/tokens.go | 15 | ||||
| -rw-r--r-- | models/image.go | 68 | ||||
| -rw-r--r-- | models/tags.go | 21 | ||||
| -rw-r--r-- | models/tokens.go | 13 | ||||
| -rw-r--r-- | models/user.go | 64 | ||||
| -rw-r--r-- | processors/sidebar.go | 46 | ||||
| -rw-r--r-- | router/routes.go | 1 | ||||
| -rw-r--r-- | static/css/main.css | 87 | ||||
| -rw-r--r-- | static/scripts/upload.js | 236 | ||||
| -rw-r--r-- | utils/email/email.go | 2 | ||||
| -rw-r--r-- | utils/minio/minio.go | 1 | ||||
| -rw-r--r-- | utils/transformers/image.go | 18 | ||||
| -rw-r--r-- | utils/transformers/links.go (renamed from utils/validators/links.go) | 2 | ||||
| -rw-r--r-- | utils/transformers/tokens.go (renamed from utils/validators/tokens.go) | 2 |
18 files changed, 525 insertions, 127 deletions
diff --git a/models/enums.go b/config/enums.go index 3334141..e825d61 100644 --- a/models/enums.go +++ b/config/enums.go @@ -1,4 +1,4 @@ -package models +package config type UserLevel int diff --git a/config/types.go b/config/types.go index a66c7a4..7ddbc84 100644 --- a/config/types.go +++ b/config/types.go @@ -41,8 +41,8 @@ type S3Config struct { Endpoint string `env:"S3_ENDPOINT" default:"localhost:9000"`
AccessKey string `env:"S3_ACCESS_KEY" default:"minioadmin"`
SecretAccessKey string `env:"S3_SECRET_KEY" default:"minioadmin"`
- BucketName string `env:"S3_BUCKET_NAME" default:"shifoo"`
- FolderPath string `env:"S3_FOLDER_PATH" default:"imageboard"`
+ BucketName string `env:"S3_BUCKET_NAME" default:"imageboard"`
+ FolderPath string `env:"S3_FOLDER_PATH" default:""`
Region string `env:"S3_REGION" default:"us-east-1"`
UseSSL bool `env:"S3_USE_SSL" default:"false"`
PublicURL string `env:"S3_PUBLIC_URL" default:""`
diff --git a/controllers/account.go b/controllers/account.go index fa3e0e7..06e29d5 100644 --- a/controllers/account.go +++ b/controllers/account.go @@ -3,7 +3,6 @@ package controllers import ( "imageboard/config" "imageboard/database" - "imageboard/models" "imageboard/utils/auth" "imageboard/utils/shortcuts" @@ -26,7 +25,7 @@ func VerifyEmailController(ctx *fiber.Ctx) error { return renderVerifyEmailError(ctx, config.ERR_VERIFY_EMAIL_MISSING_TOKEN, fiber.StatusBadRequest) } - emailToken, err := database.VerifyToken(token, models.EmailTokenTypeVerification) + emailToken, err := database.VerifyToken(token, config.EmailTokenTypeVerification) if err != nil { return renderVerifyEmailError(ctx, config.ERR_VERIFY_EMAIL_INVALID_OR_EXPIRED_TOKEN, fiber.StatusBadRequest) } diff --git a/controllers/posts.go b/controllers/posts.go index 6a2d01a..2ebd6b2 100644 --- a/controllers/posts.go +++ b/controllers/posts.go @@ -6,7 +6,7 @@ import ( "imageboard/utils/auth"
"imageboard/utils/format"
"imageboard/utils/shortcuts"
- "imageboard/utils/validators"
+ "imageboard/utils/transformers"
"io"
"net/http"
"strings"
@@ -14,6 +14,11 @@ import ( "github.com/gofiber/fiber/v2"
)
+type ImageUploadForm struct {
+ Image string `json:"image" form:"image"`
+ Rating string `json:"rating" form:"rating"`
+}
+
func PostsPageController(ctx *fiber.Ctx) error {
ctx.Locals("Title", config.PT_POST_LIST)
preferences, ok := ctx.Locals("Preferences").(config.SitePreferences)
@@ -78,6 +83,64 @@ func PostsUploadPageController(ctx *fiber.Ctx) error { })
}
+func PostsUploadPostController(ctx *fiber.Ctx) error {
+ if !auth.IsAuthenticated(ctx) {
+ return fiber.NewError(fiber.StatusForbidden, "Forbidden")
+ }
+
+ form, err := ctx.MultipartForm()
+ if err != nil {
+ return fiber.NewError(fiber.StatusBadRequest, "Invalid form data")
+ }
+
+ imageFiles := form.File["image"]
+ if len(imageFiles) == 0 {
+ return fiber.NewError(fiber.StatusBadRequest, "No image file provided")
+ }
+
+ imageFile := imageFiles[0]
+
+ rating := ctx.FormValue("rating")
+ if rating == "" {
+ rating = "safe"
+ }
+
+ sourceURL := ctx.FormValue("source_url")
+
+ // Validate file size
+ maxSize := int64(config.Upload.MaxSize)
+ if imageFile.Size > maxSize {
+ return fiber.NewError(fiber.StatusRequestEntityTooLarge,
+ "File size exceeds maximum allowed size of "+format.FileSize(maxSize))
+ }
+
+ // Validate content type
+ file, err := imageFile.Open()
+ if err != nil {
+ return fiber.NewError(fiber.StatusInternalServerError, "Failed to open uploaded file")
+ }
+ defer file.Close()
+
+ // For now, just return success - in a full implementation:
+ // 1. Generate unique filename
+ // 2. Calculate MD5 hash
+ // 3. Resize image and create different sizes
+ // 4. Upload to S3 or local storage
+ // 5. Save image record to database
+ // 6. Return image ID or details
+
+ return ctx.Status(fiber.StatusOK).JSON(fiber.Map{
+ "success": true,
+ "message": "Image uploaded successfully",
+ "data": fiber.Map{
+ "filename": imageFile.Filename,
+ "size": imageFile.Size,
+ "rating": rating,
+ "source_url": sourceURL,
+ },
+ })
+}
+
func PostsUploadImageLinkProxyController(ctx *fiber.Ctx) error {
maxSize := int64(config.Upload.MaxSize)
if !auth.IsAuthenticated(ctx) {
@@ -97,7 +160,7 @@ func PostsUploadImageLinkProxyController(ctx *fiber.Ctx) error { req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36")
- referer := validators.GetRefererForURL(url)
+ referer := transformers.GetRefererForURL(url)
if referer != "" {
req.Header.Set("Referer", referer)
}
diff --git a/database/tokens.go b/database/tokens.go index 8ff69d4..516ded0 100644 --- a/database/tokens.go +++ b/database/tokens.go @@ -2,12 +2,13 @@ package database import ( "fmt" + "imageboard/config" "imageboard/models" - "imageboard/utils/validators" + "imageboard/utils/transformers" "time" ) -func GenerateEmailToken(userID int, tokenType models.EmailTokenType) (*models.EmailToken, error) { +func GenerateEmailToken(userID int, tokenType config.EmailTokenType) (*models.EmailToken, error) { var existingToken models.EmailToken if err := DB.Where("user_id = ? AND type = ?", userID, tokenType).First(&existingToken).Error; err == nil { if err := DB.Delete(&existingToken).Error; err != nil { @@ -15,18 +16,18 @@ func GenerateEmailToken(userID int, tokenType models.EmailTokenType) (*models.Em } } - tokenValue, err := validators.GenerateRandomToken() + tokenValue, err := transformers.GenerateRandomToken() if err != nil { return nil, err } var expirationDuration time.Duration switch tokenType { - case models.EmailTokenTypeVerification: + case config.EmailTokenTypeVerification: expirationDuration = 24 * time.Hour - case models.EmailTokenTypePasswordReset: + case config.EmailTokenTypePasswordReset: expirationDuration = 1 * time.Hour - case models.EmailTokenTypeChangeEmail: + case config.EmailTokenTypeChangeEmail: expirationDuration = 1 * time.Hour default: expirationDuration = 1 * time.Hour @@ -46,7 +47,7 @@ func GenerateEmailToken(userID int, tokenType models.EmailTokenType) (*models.Em return token, nil } -func VerifyToken(token string, tokenType models.EmailTokenType) (*models.EmailToken, error) { +func VerifyToken(token string, tokenType config.EmailTokenType) (*models.EmailToken, error) { var emailToken models.EmailToken if err := DB.Where("token = ? AND type = ?", token, tokenType).First(&emailToken).Error; err != nil { return nil, err diff --git a/models/image.go b/models/image.go index 03d4c38..5feed5b 100644 --- a/models/image.go +++ b/models/image.go @@ -12,12 +12,12 @@ import ( 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"` + ImageID uint `gorm:"not null;index" json:"-"` + Image Image `gorm:"foreignKey:ImageID" json:"image"` + SizeType config.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 { @@ -56,29 +56,29 @@ func (s *ImageSize) GetFileSizeFormatted() string { 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"` + FileName string `gorm:"not null;size:255" json:"file_name"` + OriginalName string `gorm:"not null;size:255" json:"original_name"` + ContentType config.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 config.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 { @@ -104,7 +104,7 @@ func (i *Image) BeforeDelete(tx *gorm.DB) error { ) AND count > 0`, i.ID).Error } -func (i *Image) GetURL(sizeType ImageSizeType) string { +func (i *Image) GetURL(sizeType config.ImageSizeType) string { for _, size := range i.Sizes { if size.SizeType == sizeType { return size.GetURL() @@ -114,7 +114,7 @@ func (i *Image) GetURL(sizeType ImageSizeType) string { return "" } -func (i *Image) GetSize(sizeType ImageSizeType) *ImageSize { +func (i *Image) GetSize(sizeType config.ImageSizeType) *ImageSize { for _, size := range i.Sizes { if size.SizeType == sizeType { return &size @@ -124,14 +124,14 @@ func (i *Image) GetSize(sizeType ImageSizeType) *ImageSize { } func (i *Image) GetOriginalDimensions() string { - if fullSize := i.GetSize(ImageSizeTypeOriginal); fullSize != nil { + if fullSize := i.GetSize(config.ImageSizeTypeOriginal); fullSize != nil { return fullSize.GetDimensions() } return "Unknown" } func (i *Image) GetAspectRatio() string { - if fullSize := i.GetSize(ImageSizeTypeOriginal); fullSize != nil { + if fullSize := i.GetSize(config.ImageSizeTypeOriginal); fullSize != nil { if fullSize.Height == 0 { return "Unknown" } @@ -148,7 +148,7 @@ func (i *Image) GetAspectRatio() string { return "Unknown" } -func (i *Image) AddSize(tx *gorm.DB, sizeType ImageSizeType, width, height int, fileSize int64) (*ImageSize, error) { +func (i *Image) AddSize(tx *gorm.DB, sizeType config.ImageSizeType, width, height int, fileSize int64) (*ImageSize, error) { if width <= 0 || height <= 0 { return nil, fmt.Errorf("image dimensions must be greater than zero") } diff --git a/models/tags.go b/models/tags.go index 61f7013..83f0735 100644 --- a/models/tags.go +++ b/models/tags.go @@ -2,6 +2,7 @@ package models import ( "fmt" + "imageboard/config" "imageboard/utils/validators" "strings" @@ -10,15 +11,15 @@ import ( 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"` + Name string `gorm:"not null;uniqueIndex;size:100" json:"name"` + Type config.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 { @@ -80,7 +81,7 @@ func SearchTagsExcluding(tx *gorm.DB, query string, imageID uint, limit int) ([] return tags, err } -func FindOrCreateTag(tx *gorm.DB, name string, tagType TagType) (*Tag, error) { +func FindOrCreateTag(tx *gorm.DB, name string, tagType config.TagType) (*Tag, error) { name = strings.TrimSpace(strings.ToLower(name)) // First check for active tag diff --git a/models/tokens.go b/models/tokens.go index c53ea2e..c635c4e 100644 --- a/models/tokens.go +++ b/models/tokens.go @@ -1,6 +1,7 @@ package models import ( + "imageboard/config" "time" "gorm.io/gorm" @@ -8,12 +9,12 @@ import ( type EmailToken struct { gorm.Model - UserID uint `gorm:"not null;index" json:"user_id"` - Token string `gorm:"uniqueIndex;not null;size:64" json:"token"` - Type EmailTokenType `gorm:"not null;size:20" json:"type"` - ExpiresAt time.Time `gorm:"not null" json:"expires_at"` - UsedAt *time.Time `gorm:"default:null" json:"used_at"` - User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + UserID uint `gorm:"not null;index" json:"user_id"` + Token string `gorm:"uniqueIndex;not null;size:64" json:"token"` + Type config.EmailTokenType `gorm:"not null;size:20" json:"type"` + ExpiresAt time.Time `gorm:"not null" json:"expires_at"` + UsedAt *time.Time `gorm:"default:null" json:"used_at"` + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` } func (et *EmailToken) IsExpired() bool { diff --git a/models/user.go b/models/user.go index 546f600..ecb139f 100644 --- a/models/user.go +++ b/models/user.go @@ -13,23 +13,23 @@ import ( 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"` + 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 config.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 { @@ -76,7 +76,7 @@ func (u *User) BeforeCreate(tx *gorm.DB) error { } if userCount == 0 { - u.Level = UserLevelSuperAdmin // First user becomes Super Admin + u.Level = config.UserLevelSuperAdmin // First user becomes Super Admin } if len(u.Password) < config.Server.MinPasswordLength { @@ -143,23 +143,23 @@ func (u *User) CanLogin() bool { } func (u *User) IsAdmin() bool { - return u.Level >= UserLevelAdmin + return u.Level >= config.UserLevelAdmin } func (u *User) IsModerator() bool { - return u.IsActive() && u.Level >= UserLevelModerator + return u.IsActive() && u.Level >= config.UserLevelModerator } func (u *User) IsJanitor() bool { - return u.IsActive() && u.Level >= UserLevelJanitor + return u.IsActive() && u.Level >= config.UserLevelJanitor } func (u *User) IsContributor() bool { - return u.IsActive() && u.Level >= UserLevelContributor + return u.IsActive() && u.Level >= config.UserLevelContributor } func (u *User) IsMember() bool { - return u.IsActive() && u.Level >= UserLevelMember + return u.IsActive() && u.Level >= config.UserLevelMember } func (u *User) CanUpload() bool { @@ -206,28 +206,28 @@ func (u *User) CanEditUser(targetUser *User) bool { return (u.IsAdmin() || u.IsModerator()) && targetUser.Level < u.Level } -func (u *User) CanPromoteUser(targetUser *User, newLevel UserLevel) bool { +func (u *User) CanPromoteUser(targetUser *User, newLevel config.UserLevel) bool { if u.ID == targetUser.ID || targetUser.IsDeleted { return false } - if u.Level <= UserLevelContributor { + if u.Level <= config.UserLevelContributor { return false } - return newLevel > UserLevelMember && newLevel <= u.Level && newLevel <= UserLevelAdmin + return newLevel > config.UserLevelMember && newLevel <= u.Level && newLevel <= config.UserLevelAdmin } -func (u *User) CanDemoteUser(targetUser *User, newLevel UserLevel) bool { +func (u *User) CanDemoteUser(targetUser *User, newLevel config.UserLevel) bool { if u.ID == targetUser.ID || targetUser.IsDeleted { return false } - if u.Level <= UserLevelContributor { + if u.Level <= config.UserLevelContributor { return false } - return newLevel >= UserLevelMember && newLevel < u.Level && newLevel <= UserLevelAdmin + return newLevel >= config.UserLevelMember && newLevel < u.Level && newLevel <= config.UserLevelAdmin } func (u *User) CanDisableUser(targetUser *User) bool { @@ -255,7 +255,7 @@ func (u *User) CanDeleteUser(targetUser *User) bool { return true // Users can delete their own account } - if u.Level <= UserLevelContributor { + if u.Level <= config.UserLevelContributor { return false } @@ -272,9 +272,9 @@ func (u *User) CanMakeUserPostsRequireApproval(targetUser *User) bool { func (u *User) GetDailyPostLimit() int { switch u.Level { - case UserLevelMember: + case config.UserLevelMember: return 10 - case UserLevelContributor: + case config.UserLevelContributor: return 25 default: return -1 // No limit for Janitors, Moderators, and Admins diff --git a/processors/sidebar.go b/processors/sidebar.go index 6fe2fdf..7a5d5fd 100644 --- a/processors/sidebar.go +++ b/processors/sidebar.go @@ -13,21 +13,21 @@ func SidebarContextProcessor(ctx *fiber.Ctx) error { popularTags, popularTagsErr := database.GetPopularTags(15) if popularTagsErr != nil || len(popularTags) == 0 { mockTags := []models.Tag{ - {Name: "anime", Type: models.TagTypeGeneral, Count: 1523}, - {Name: "manga", Type: models.TagTypeGeneral, Count: 892}, - {Name: "kawaii", Type: models.TagTypeGeneral, Count: 756}, - {Name: "retro", Type: models.TagTypeMeta, Count: 634}, - {Name: "y2k", Type: models.TagTypeMeta, Count: 511}, - {Name: "aesthetic", Type: models.TagTypeGeneral, Count: 445}, - {Name: "sakura", Type: models.TagTypeArtist, Count: 389}, - {Name: "studio_ghibli", Type: models.TagTypeCopyright, Count: 312}, - {Name: "totoro", Type: models.TagTypeCharacter, Count: 298}, - {Name: "sailor_moon", Type: models.TagTypeCharacter, Count: 267}, - {Name: "pokemon", Type: models.TagTypeCopyright, Count: 234}, - {Name: "pixiv", Type: models.TagTypeMeta, Count: 198}, - {Name: "digital_art", Type: models.TagTypeMeta, Count: 176}, - {Name: "watercolor", Type: models.TagTypeGeneral, Count: 145}, - {Name: "minimalist", Type: models.TagTypeGeneral, Count: 123}, + {Name: "anime", Type: config.TagTypeGeneral, Count: 1523}, + {Name: "manga", Type: config.TagTypeGeneral, Count: 892}, + {Name: "kawaii", Type: config.TagTypeGeneral, Count: 756}, + {Name: "retro", Type: config.TagTypeMeta, Count: 634}, + {Name: "y2k", Type: config.TagTypeMeta, Count: 511}, + {Name: "aesthetic", Type: config.TagTypeGeneral, Count: 445}, + {Name: "sakura", Type: config.TagTypeArtist, Count: 389}, + {Name: "studio_ghibli", Type: config.TagTypeCopyright, Count: 312}, + {Name: "totoro", Type: config.TagTypeCharacter, Count: 298}, + {Name: "sailor_moon", Type: config.TagTypeCharacter, Count: 267}, + {Name: "pokemon", Type: config.TagTypeCopyright, Count: 234}, + {Name: "pixiv", Type: config.TagTypeMeta, Count: 198}, + {Name: "digital_art", Type: config.TagTypeMeta, Count: 176}, + {Name: "watercolor", Type: config.TagTypeGeneral, Count: 145}, + {Name: "minimalist", Type: config.TagTypeGeneral, Count: 123}, } ctx.Locals("PopularTags", mockTags) } else { @@ -37,14 +37,14 @@ func SidebarContextProcessor(ctx *fiber.Ctx) error { recentTags, recentTagsErr := database.GetRecentTags(10) if recentTagsErr != nil || len(recentTags) == 0 { mockRecentTags := []models.Tag{ - {Name: "cyberpunk", Type: models.TagTypeGeneral, Count: 23}, - {Name: "vaporwave", Type: models.TagTypeMeta, Count: 45}, - {Name: "synthwave", Type: models.TagTypeGeneral, Count: 12}, - {Name: "retrocomputing", Type: models.TagTypeMeta, Count: 8}, - {Name: "neon", Type: models.TagTypeGeneral, Count: 67}, - {Name: "glitch", Type: models.TagTypeMeta, Count: 34}, - {Name: "pixel_art", Type: models.TagTypeGeneral, Count: 89}, - {Name: "lo_fi", Type: models.TagTypeGeneral, Count: 56}, + {Name: "cyberpunk", Type: config.TagTypeGeneral, Count: 23}, + {Name: "vaporwave", Type: config.TagTypeMeta, Count: 45}, + {Name: "synthwave", Type: config.TagTypeGeneral, Count: 12}, + {Name: "retrocomputing", Type: config.TagTypeMeta, Count: 8}, + {Name: "neon", Type: config.TagTypeGeneral, Count: 67}, + {Name: "glitch", Type: config.TagTypeMeta, Count: 34}, + {Name: "pixel_art", Type: config.TagTypeGeneral, Count: 89}, + {Name: "lo_fi", Type: config.TagTypeGeneral, Count: 56}, } ctx.Locals("RecentTags", mockRecentTags) } else { diff --git a/router/routes.go b/router/routes.go index 8c97318..586f970 100644 --- a/router/routes.go +++ b/router/routes.go @@ -13,6 +13,7 @@ func Initialize(router *fiber.App) { posts := router.Group("/posts")
posts.Get("/", controllers.PostsPageController)
posts.Get("/new", controllers.PostsUploadPageController)
+ posts.Post("/new", controllers.PostsUploadPostController)
posts.Get("/new/ilinkfetch", controllers.PostsUploadImageLinkProxyController)
login := router.Group("/login")
diff --git a/static/css/main.css b/static/css/main.css index ca70e63..969b156 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -605,4 +605,91 @@ footer::before { .ib-loader-seg:nth-child(12) { top: 3px; left: 5px; +} + +/* Upload All Button Styles */ +.upload-all-btn { + padding: 12px 24px; + cursor: pointer; + transition: all 0.3s ease; + width: auto; + min-width: 120px; +} + +.upload-all-btn:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.upload-all-btn.uploading { + background-color: #444400; + border-color: #ffff00; + color: #ffffcc; +} + +.upload-all-btn.success { + background-color: #004400; + border-color: #00ff00; + color: #ccffcc; + animation: successPulse 1s ease-in-out; +} + +.upload-all-btn.warning { + background-color: #664400; + border-color: #ffaa00; + color: #ffeecc; +} + +.upload-all-btn.error { + background-color: #440000; + border-color: #ff0000; + color: #ffcccc; +} + +@keyframes successPulse { + 0% { + box-shadow: 0 0 0 rgba(0, 255, 0, 0.4); + } + + 50% { + box-shadow: 0 0 16px rgba(0, 255, 0, 0.6); + } + + 100% { + box-shadow: 0 0 0 rgba(0, 255, 0, 0.4); + } +} + +.preview-area.uploaded { + opacity: 0.7; + background-color: rgba(0, 68, 0, 0.1); + border-left: 3px solid #00ff00; + padding-left: 8px; +} + +.preview-area.upload-error { + background-color: rgba(68, 0, 0, 0.1); + border-left: 3px solid #ff0000; + padding-left: 8px; +} + +.preview-remove-btn.uploaded { + background-color: #004400; + border-color: #00ff00; + color: #ccffcc; + cursor: default; +} + +.preview-remove-btn.uploading { + background-color: #333333; + border-color: #666666; + color: #999999; + cursor: not-allowed; + opacity: 0.6; +} + +.preview-remove-btn.error { + background-color: #440000; + border-color: #ff0000; + color: #ffcccc; }
\ No newline at end of file diff --git a/static/scripts/upload.js b/static/scripts/upload.js index 309b0da..d190014 100644 --- a/static/scripts/upload.js +++ b/static/scripts/upload.js @@ -6,6 +6,12 @@ const imageBlobMapping = new Map(); /** + * Tracks whether an upload is currently in progress + * @type {boolean} + */ +let isUploading = false; + +/** * Shows or hides the Upload All button in the .upload-area container based on imageBlobMapping size. * The button is created once and appended/removed as needed. * @function @@ -19,11 +25,8 @@ function updateUploadAllBtn() { uploadAllBtn.type = 'button'; uploadAllBtn.textContent = 'Upload All'; uploadAllBtn.className = 'upload-all-btn'; - /** - * TODO: Implement actual upload logic here - */ - uploadAllBtn.onclick = function () { - alert('Upload All clicked!'); + uploadAllBtn.onclick = async function () { + await uploadAllImages(); }; } if (imageBlobMapping.size > 0) { @@ -95,6 +98,7 @@ function createPreviewElement(key, blob, type, nameOrUrl) { removeBtn.textContent = 'Remove'; removeBtn.className = 'preview-remove-btn'; removeBtn.onclick = () => { + if (isUploading) return; // Prevent removal during upload previewElement.remove(); imageBlobMapping.delete(key); updateUploadAllBtn(); @@ -369,4 +373,226 @@ function handleFiles(files) { updateUploadAllBtn(); } +/** + * Uploads all images in the imageBlobMapping to the server. + * Shows progress and handles success/error states. + */ +async function uploadAllImages() { + if (imageBlobMapping.size === 0) return; + + const totalImages = imageBlobMapping.size; + let uploadedCount = 0; + let hasErrors = false; + + // Set upload state and disable remove buttons + isUploading = true; + disableAllRemoveButtons(); + + // Disable the upload button and show progress + uploadAllBtn.disabled = true; + updateUploadButtonText(uploadedCount, totalImages, false); + + try { + for (const [key, imageData] of imageBlobMapping) { + try { + // Get the selected rating for this image + const selectedRating = getSelectedRating(key); + imageData.rating = selectedRating; + + // Create and submit form for this image + await uploadSingleImage(imageData, selectedRating); + + // Mark this image as successfully uploaded + markImageAsUploaded(imageData.previewElement); + uploadedCount++; + updateUploadButtonText(uploadedCount, totalImages, false); + + } catch (error) { + console.error(`Failed to upload image ${key}:`, error); + markImageAsError(imageData.previewElement, error.message); + hasErrors = true; + } + } + + // Update button text based on results + if (hasErrors) { + updateUploadButtonText(uploadedCount, totalImages, true); + } else { + uploadAllBtn.textContent = `✓ All ${totalImages} images uploaded successfully!`; + uploadAllBtn.className = 'upload-all-btn success'; + + // Clear all uploaded images after a delay + setTimeout(() => { + clearAllUploadedImages(); + }, 2000); + } + + } catch (error) { + console.error('Upload process failed:', error); + uploadAllBtn.textContent = 'Upload failed - Try again'; + uploadAllBtn.className = 'upload-all-btn error'; + uploadAllBtn.disabled = false; + } finally { + // Reset upload state + isUploading = false; + enableAllRemoveButtons(); + } +} + +/** + * Disables all remove buttons to prevent removal during upload. + */ +function disableAllRemoveButtons() { + const removeButtons = document.querySelectorAll('.preview-remove-btn:not(.uploaded):not(.error)'); + removeButtons.forEach(btn => { + btn.disabled = true; + btn.classList.add('uploading'); + }); +} + +/** + * Re-enables all remove buttons after upload is complete. + */ +function enableAllRemoveButtons() { + const removeButtons = document.querySelectorAll('.preview-remove-btn:not(.uploaded):not(.error)'); + removeButtons.forEach(btn => { + btn.disabled = false; + btn.classList.remove('uploading'); + }); +} + +/** + * Gets the selected rating for an image from its radio buttons. + * @param {string} key - The key for the image + * @returns {string} The selected rating + */ +function getSelectedRating(key) { + const checkedInput = document.querySelector(`input[name="rating-${key}"]:checked`); + return checkedInput ? checkedInput.value : 'safe'; +} + +/** + * Uploads a single image to the server by creating a FormData and submitting it. + * @param {Object} imageData - The image data object + * @param {string} rating - The selected rating + * @returns {Promise<void>} + */ +async function uploadSingleImage(imageData, rating) { + const formData = new FormData(); + + // Add the image blob to the form + if (imageData.type === 'local') { + formData.append('image', imageData.blob, imageData.nameOrUrl); + } else { + // For link images, create a File object from the blob + const file = new File([imageData.blob], 'image.jpg', { type: imageData.blob.type }); + formData.append('image', file); + formData.append('source_url', imageData.nameOrUrl); + } + + // Add the rating + formData.append('rating', rating); + + // Submit to the backend + const response = await fetch('/posts/new', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(errorText || `Upload failed with status ${response.status}`); + } + + return response; +} + +/** + * Updates the upload button text to show progress. + * @param {number} current - Current number of uploaded images + * @param {number} total - Total number of images to upload + * @param {boolean} hasErrors - Whether there were errors during upload + */ +function updateUploadButtonText(current, total, hasErrors) { + if (hasErrors) { + uploadAllBtn.textContent = `⚠ Uploaded ${current}/${total} (some failed)`; + uploadAllBtn.className = 'upload-all-btn warning'; + uploadAllBtn.disabled = false; + } else if (current === total) { + uploadAllBtn.textContent = `✓ All ${total} images uploaded!`; + uploadAllBtn.className = 'upload-all-btn success'; + } else { + uploadAllBtn.textContent = `⏳ Uploading (${current}/${total})`; + uploadAllBtn.className = 'upload-all-btn uploading'; + } +} + +/** + * Marks an image preview as successfully uploaded. + * @param {HTMLElement} previewElement - The preview element to mark + */ +function markImageAsUploaded(previewElement) { + previewElement.classList.add('uploaded'); + const removeBtn = previewElement.querySelector('.preview-remove-btn'); + if (removeBtn) { + removeBtn.textContent = '✓ Uploaded'; + removeBtn.disabled = true; + removeBtn.className = 'preview-remove-btn uploaded'; + } +} + +/** + * Marks an image preview as failed to upload. + * @param {HTMLElement} previewElement - The preview element to mark + * @param {string} errorMessage - The error message to display + */ +function markImageAsError(previewElement, errorMessage) { + previewElement.classList.add('upload-error'); + const removeBtn = previewElement.querySelector('.preview-remove-btn'); + if (removeBtn) { + removeBtn.textContent = '✗ Failed'; + removeBtn.className = 'preview-remove-btn error'; + removeBtn.title = errorMessage; + } +} + +/** + * Clears all successfully uploaded images from the preview area. + */ +function clearAllUploadedImages() { + const uploadedElements = document.querySelectorAll('.preview-area.uploaded'); + uploadedElements.forEach(element => { + const key = getImageKeyFromElement(element); + if (key) { + imageBlobMapping.delete(key); + } + element.remove(); + }); + updateUploadAllBtn(); + + // Reset button state if all images are cleared + if (imageBlobMapping.size === 0) { + if (uploadAllBtn) { + uploadAllBtn.textContent = 'Upload All'; + uploadAllBtn.className = 'upload-all-btn'; + uploadAllBtn.disabled = false; + } + } +} + +/** + * Gets the image key from a preview element by looking at its radio button names. + * @param {HTMLElement} previewElement - The preview element + * @returns {string|null} The image key or null if not found + */ +function getImageKeyFromElement(previewElement) { + const radioInput = previewElement.querySelector('input[type="radio"]'); + if (radioInput && radioInput.name) { + // Extract key from "rating-{key}" format + const match = radioInput.name.match(/^rating-(.+)$/); + return match ? match[1] : null; + } + return null; +} + setupLocalImageUpload(); diff --git a/utils/email/email.go b/utils/email/email.go index 5019627..168da25 100644 --- a/utils/email/email.go +++ b/utils/email/email.go @@ -36,7 +36,7 @@ func SendMail(to, subject, body string) error { } func SendVerificationEmail(user *models.User) error { - token, err := database.GenerateEmailToken(int(user.ID), models.EmailTokenTypeVerification) + token, err := database.GenerateEmailToken(int(user.ID), config.EmailTokenTypeVerification) if err != nil { return fmt.Errorf("failed to generate verification token: %w", err) } diff --git a/utils/minio/minio.go b/utils/minio/minio.go new file mode 100644 index 0000000..c5576b1 --- /dev/null +++ b/utils/minio/minio.go @@ -0,0 +1 @@ +package minio diff --git a/utils/transformers/image.go b/utils/transformers/image.go new file mode 100644 index 0000000..a88d8bb --- /dev/null +++ b/utils/transformers/image.go @@ -0,0 +1,18 @@ +package transformers + +import "imageboard/config" + +func ConvertStringRatingToType(rating string) (config.Rating, error) { + switch rating { + case "safe": + return config.RatingSafe, nil + case "questionable": + return config.RatingQuestionable, nil + case "sensitive": + return config.RatingSensitive, nil + case "explicit": + return config.RatingExplicit, nil + default: + return config.RatingSafe, nil + } +} diff --git a/utils/validators/links.go b/utils/transformers/links.go index cc9dd9b..1ce684c 100644 --- a/utils/validators/links.go +++ b/utils/transformers/links.go @@ -1,4 +1,4 @@ -package validators +package transformers import "strings" diff --git a/utils/validators/tokens.go b/utils/transformers/tokens.go index f377c2e..7ad36ed 100644 --- a/utils/validators/tokens.go +++ b/utils/transformers/tokens.go @@ -1,4 +1,4 @@ -package validators +package transformers import ( "crypto/rand" |
