aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2025-07-17 10:47:08 +0530
committerBobby <[email protected]>2025-07-17 10:47:08 +0530
commitb0ba363696a758a8d0637107bd29a0a9ac1382d4 (patch)
treef11acd0ebc5a4b3d633a6a596deee92b575f8f1c
parent94cca506f6d1461bf38afa5b0e38d778391b8d39 (diff)
downloadimageboard-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.go4
-rw-r--r--controllers/account.go3
-rw-r--r--controllers/posts.go67
-rw-r--r--database/tokens.go15
-rw-r--r--models/image.go68
-rw-r--r--models/tags.go21
-rw-r--r--models/tokens.go13
-rw-r--r--models/user.go64
-rw-r--r--processors/sidebar.go46
-rw-r--r--router/routes.go1
-rw-r--r--static/css/main.css87
-rw-r--r--static/scripts/upload.js236
-rw-r--r--utils/email/email.go2
-rw-r--r--utils/minio/minio.go1
-rw-r--r--utils/transformers/image.go18
-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"