From f826397be8178dc3be812ac95c5d9219a7924c32 Mon Sep 17 00:00:00 2001 From: Bobby Date: Thu, 17 Jul 2025 14:47:43 +0530 Subject: image upload feature --- controllers/posts.go | 492 +++++++++++++++++++++---------------------- database/database.go | 2 +- database/images.go | 46 ++++ go.mod | 20 +- go.sum | 37 +++- models/image.go | 4 +- utils/format/files.go | 10 +- utils/minio/minio.go | 47 +++++ utils/transformers/image.go | 54 ++--- utils/transformers/tokens.go | 13 ++ 10 files changed, 434 insertions(+), 291 deletions(-) diff --git a/controllers/posts.go b/controllers/posts.go index dd612fb..f77c24c 100644 --- a/controllers/posts.go +++ b/controllers/posts.go @@ -1,246 +1,246 @@ -package controllers - -import ( - "image" - "imageboard/config" - "imageboard/database" - "imageboard/models" - "imageboard/utils/auth" - "imageboard/utils/format" - "imageboard/utils/shortcuts" - "imageboard/utils/transformers" - "io" - "net/http" - "strings" - - "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) - if !ok { - return fiber.NewError(fiber.StatusInternalServerError, "Invalid preferences type") - } - - request, ok := ctx.Locals("Request").(config.Request) - if !ok { - return fiber.NewError(fiber.StatusInternalServerError, "Invalid request type") - } - - queryTags := "" - queryRatings := map[string]bool{} - for _, param := range request.Query { - switch param.Key { - case "tags": - queryTags = param.Value - case "rating": - queryRatings[param.Value] = true - } - } - - if len(queryRatings) == 0 { - for _, rating := range []string{"safe", "questionable", "sensitive"} { - queryRatings[rating] = true - } - } - - posts, err := database.GetPosts(preferences.PostsPerPage) - - return shortcuts.Render(ctx, config.TEMPLATE_POST_LIST, fiber.Map{ - "Posts": posts, - "Error": err, - "QueryTags": queryTags, - "QueryRatings": queryRatings, - }) -} - -func PostsUploadPageController(ctx *fiber.Ctx) error { - ctx.Locals("Title", config.PT_POST_NEW) - if !auth.IsAuthenticated(ctx) { - loginURL := auth.GetLoginURLWithRedirect(ctx) - ctx.Set("Location", loginURL) - ctx.Status(fiber.StatusFound) - return nil - } - - allowedTypes := []string{} - for t := range strings.SplitSeq(config.Upload.AllowedTypes, ",") { - if idx := strings.Index(t, "/"); idx != -1 && idx+1 < len(t) { - subtype := t[idx+1:] - if subtype != "" { - allowedTypes = append(allowedTypes, "."+subtype) - } - } - } - - return shortcuts.Render(ctx, config.TEMPLATE_POST_NEW, fiber.Map{ - "AllowedTypes": allowedTypes, - "MaxSize": format.FileSize(int64(config.Upload.MaxSize)), - }) -} - -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] - - contentType := imageFile.Header.Get("Content-Type") - if !strings.HasPrefix(contentType, "image/") { - return fiber.NewError(fiber.StatusBadRequest, "Uploaded file is not an image") - } - - if !strings.Contains(config.Upload.AllowedTypes, contentType) { - return fiber.NewError(fiber.StatusBadRequest, "Uploaded image type is not allowed") - } - - maxSize := int64(config.Upload.MaxSize) - if imageFile.Size > maxSize { - return fiber.NewError(fiber.StatusRequestEntityTooLarge, - "File size exceeds maximum allowed size of "+format.FileSize(maxSize)) - } - - sourceURL := ctx.FormValue("source_url") - - file, err := imageFile.Open() - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to open uploaded file") - } - defer file.Close() - - imageData, err := io.ReadAll(file) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to read uploaded file") - } - - decodedImage, format, err := format.DecodeImage(imageData) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to decode image: "+err.Error()) - } - - var fileName string - if sourceURL != "" { - fileName = transformers.CreateUniqueFileName(sourceURL, format) - } else { - fileName = transformers.CreateUniqueFileName(imageFile.Filename, format) - } - - rating := ctx.FormValue("rating") - if rating == "" { - rating = "safe" - } - - imageSizes := make(map[config.ImageSizeType]struct { - imageSize models.ImageSize - image image.Image - }) - isizeArray := []config.ImageSizeType{ - config.ImageSizeTypeIcon, - config.ImageSizeTypeThumbnail, - config.ImageSizeTypeSmall, - config.ImageSizeTypeMedium, - config.ImageSizeTypeLarge, - config.ImageSizeTypeOriginal, - } - - for _, sizeType := range isizeArray { - size, img, err := transformers.TransformImageToVariant(decodedImage, sizeType) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to process image: "+err.Error()) - } - size.ImageID = 0 // This will be set later when saving the image - imageSizes[sizeType] = struct { - imageSize models.ImageSize - image image.Image - }{ - imageSize: size, - image: img, - } - } - - // 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": fileName, - "size": imageFile.Size, - "rating": rating, - "source_url": sourceURL, - }, - }) -} - -func PostsUploadImageLinkProxyController(ctx *fiber.Ctx) error { - maxSize := int64(config.Upload.MaxSize) - if !auth.IsAuthenticated(ctx) { - return fiber.NewError(fiber.StatusForbidden, "Forbidden") - } - - url := ctx.Query("url") - if url == "" { - return fiber.NewError(fiber.StatusBadRequest, "Missing url parameter") - } - - client := &http.Client{} - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid URL") - } - - 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 := transformers.GetRefererForURL(url) - if referer != "" { - req.Header.Set("Referer", referer) - } - - resp, err := client.Do(req) - if err != nil { - return fiber.NewError(fiber.StatusBadGateway, "Failed to fetch image") - } - if resp.StatusCode != 200 { - return fiber.NewError(fiber.StatusBadGateway, "Failed to fetch image") - } - defer resp.Body.Close() - - contentType := resp.Header.Get("Content-Type") - if !strings.HasPrefix(contentType, "image/") { - return fiber.NewError(fiber.StatusBadRequest, "URL does not point to an image") - } - - ctx.Set("Content-Type", contentType) - ctx.Set("Cache-Control", "no-store") - buf, err := io.ReadAll(resp.Body) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to read image data") - } - if int64(len(buf)) > maxSize { - return fiber.NewError(fiber.StatusRequestEntityTooLarge, "Image exceeds maximum allowed size of "+format.FileSize(maxSize)) - } - return ctx.Send(buf) -} +package controllers + +import ( + "imageboard/config" + "imageboard/database" + "imageboard/utils/auth" + "imageboard/utils/format" + "imageboard/utils/minio" + "imageboard/utils/shortcuts" + "imageboard/utils/transformers" + "io" + "net/http" + "strings" + + "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) + if !ok { + return fiber.NewError(fiber.StatusInternalServerError, "Invalid preferences type") + } + + request, ok := ctx.Locals("Request").(config.Request) + if !ok { + return fiber.NewError(fiber.StatusInternalServerError, "Invalid request type") + } + + queryTags := "" + queryRatings := map[string]bool{} + for _, param := range request.Query { + switch param.Key { + case "tags": + queryTags = param.Value + case "rating": + queryRatings[param.Value] = true + } + } + + if len(queryRatings) == 0 { + for _, rating := range []string{"safe", "questionable", "sensitive"} { + queryRatings[rating] = true + } + } + + posts, err := database.GetPosts(preferences.PostsPerPage) + + return shortcuts.Render(ctx, config.TEMPLATE_POST_LIST, fiber.Map{ + "Posts": posts, + "Error": err, + "QueryTags": queryTags, + "QueryRatings": queryRatings, + }) +} + +func PostsUploadPageController(ctx *fiber.Ctx) error { + ctx.Locals("Title", config.PT_POST_NEW) + if !auth.IsAuthenticated(ctx) { + loginURL := auth.GetLoginURLWithRedirect(ctx) + ctx.Set("Location", loginURL) + ctx.Status(fiber.StatusFound) + return nil + } + + allowedTypes := []string{} + for t := range strings.SplitSeq(config.Upload.AllowedTypes, ",") { + if idx := strings.Index(t, "/"); idx != -1 && idx+1 < len(t) { + subtype := t[idx+1:] + if subtype != "" { + allowedTypes = append(allowedTypes, "."+subtype) + } + } + } + + return shortcuts.Render(ctx, config.TEMPLATE_POST_NEW, fiber.Map{ + "AllowedTypes": allowedTypes, + "MaxSize": format.FileSize(int64(config.Upload.MaxSize)), + }) +} + +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] + + contentType := imageFile.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "image/") { + return fiber.NewError(fiber.StatusBadRequest, "Uploaded file is not an image") + } + + if !strings.Contains(config.Upload.AllowedTypes, contentType) { + return fiber.NewError(fiber.StatusBadRequest, "Uploaded image type is not allowed") + } + + maxSize := int64(config.Upload.MaxSize) + if imageFile.Size > maxSize { + return fiber.NewError(fiber.StatusRequestEntityTooLarge, + "File size exceeds maximum allowed size of "+format.FileSize(maxSize)) + } + + sourceURL := ctx.FormValue("source_url") + + file, err := imageFile.Open() + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to open uploaded file") + } + defer file.Close() + + imageData, err := io.ReadAll(file) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to read uploaded file") + } + + decodedImage, imageFormat, err := format.DecodeImage(imageData) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to decode image: "+err.Error()) + } + + var fileName string + if sourceURL != "" { + fileName = transformers.CreateUniqueFileName(sourceURL, imageFormat) + } else { + fileName = transformers.CreateUniqueFileName(imageFile.Filename, imageFormat) + } + + rating := ctx.FormValue("rating") + if rating == "" { + rating = "safe" + } + + isizeArray := []config.ImageSizeType{ + config.ImageSizeTypeIcon, + config.ImageSizeTypeThumbnail, + config.ImageSizeTypeSmall, + config.ImageSizeTypeMedium, + config.ImageSizeTypeLarge, + config.ImageSizeTypeOriginal, + } + + currentUser := auth.GetCurrentUser(ctx) + if currentUser == nil { + return fiber.NewError(fiber.StatusUnauthorized, "User not authenticated") + } + + md5Hash := transformers.GenerateMD5Hash(imageData) + + dbImage, err := database.CreateImage( + fileName, + contentType, + md5Hash, + sourceURL, + rating, + currentUser.ID, + currentUser.PostsRequireApproval, + ) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create image record: "+err.Error()) + } + + for _, sizeType := range isizeArray { + width, height, fileSize, imageData, err := transformers.TransformImageToVariant(decodedImage, sizeType) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to process image: "+err.Error()) + } + + err = minio.UploadImage(imageData, sizeType, fileName, contentType) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload image: "+err.Error()) + } + + _, err = database.CreateImageSize(dbImage.ID, sizeType, width, height, fileSize) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create image size record: "+err.Error()) + } + } + + return ctx.SendStatus(fiber.StatusOK) +} + +func PostsUploadImageLinkProxyController(ctx *fiber.Ctx) error { + maxSize := int64(config.Upload.MaxSize) + if !auth.IsAuthenticated(ctx) { + return fiber.NewError(fiber.StatusForbidden, "Forbidden") + } + + url := ctx.Query("url") + if url == "" { + return fiber.NewError(fiber.StatusBadRequest, "Missing url parameter") + } + + client := &http.Client{} + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid URL") + } + + 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 := transformers.GetRefererForURL(url) + if referer != "" { + req.Header.Set("Referer", referer) + } + + resp, err := client.Do(req) + if err != nil { + return fiber.NewError(fiber.StatusBadGateway, "Failed to fetch image") + } + if resp.StatusCode != 200 { + return fiber.NewError(fiber.StatusBadGateway, "Failed to fetch image") + } + defer resp.Body.Close() + + contentType := resp.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "image/") { + return fiber.NewError(fiber.StatusBadRequest, "URL does not point to an image") + } + + ctx.Set("Content-Type", contentType) + ctx.Set("Cache-Control", "no-store") + buf, err := io.ReadAll(resp.Body) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to read image data") + } + if int64(len(buf)) > maxSize { + return fiber.NewError(fiber.StatusRequestEntityTooLarge, "Image exceeds maximum allowed size of "+format.FileSize(maxSize)) + } + return ctx.Send(buf) +} diff --git a/database/database.go b/database/database.go index 0065479..dedae59 100644 --- a/database/database.go +++ b/database/database.go @@ -31,7 +31,7 @@ func init() { logLevel := logger.Silent if config.Server.IsDevMode { - logLevel = logger.Silent + logLevel = logger.Info } dialector := postgres.Open(dsn) diff --git a/database/images.go b/database/images.go index 8fa1a47..35efa7b 100644 --- a/database/images.go +++ b/database/images.go @@ -1,8 +1,10 @@ package database import ( + "imageboard/config" "imageboard/models" "imageboard/utils/format" + "imageboard/utils/transformers" "time" ) @@ -32,3 +34,47 @@ func GetTotalStorageSize() (string, error) { return format.FileSize(totalSize), nil } + +func CreateImage(fileName, contentType, md5Hash, sourceURL, rating string, uploaderID uint, requiresApproval bool) (*models.Image, error) { + ratingEnum, err := transformers.ConvertStringRatingToType(rating) + if err != nil { + return nil, err + } + + contentTypeEnum, err := transformers.ConvertStringToContentType(contentType) + if err != nil { + return nil, err + } + + image := models.Image{ + FileName: fileName, + ContentType: contentTypeEnum, + MD5Hash: md5Hash, + SourceURL: sourceURL, + Rating: ratingEnum, + UploaderID: uploaderID, + IsApproved: !requiresApproval, + } + + if err := DB.Create(&image).Error; err != nil { + return nil, err + } + + return &image, nil +} + +func CreateImageSize(imageID uint, sizeType config.ImageSizeType, width, height int, fileSize int64) (*models.ImageSize, error) { + imageSize := models.ImageSize{ + ImageID: imageID, + SizeType: sizeType, + Width: width, + Height: height, + FileSize: fileSize, + } + + if err := DB.Create(&imageSize).Error; err != nil { + return nil, err + } + + return &imageSize, nil +} diff --git a/go.mod b/go.mod index 4027b9e..6b30c0e 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,11 @@ require ( github.com/gofiber/fiber/v2 v2.52.8 github.com/gofiber/storage/postgres/v2 v2.0.3 github.com/gofiber/template/django/v3 v3.1.14 + github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 - golang.org/x/crypto v0.31.0 + github.com/minio/minio-go/v7 v7.0.94 + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 + golang.org/x/crypto v0.36.0 golang.org/x/image v0.29.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.30.0 @@ -15,26 +18,35 @@ require ( require ( github.com/andybalholm/brotli v1.1.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/flosch/pongo2/v6 v6.0.0 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/gofiber/template v1.8.3 // indirect github.com/gofiber/utils v1.1.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/klauspost/compress v1.17.9 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/minio/crc64nvme v1.0.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.3.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/net v0.38.0 // indirect golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.27.0 // indirect ) diff --git a/go.sum b/go.sum index 205cefb..d77af21 100644 --- a/go.sum +++ b/go.sum @@ -3,8 +3,14 @@ github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer5 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU= github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gofiber/fiber/v2 v2.52.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0x4= github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/storage/postgres/v2 v2.0.3 h1:pN2PAKZMhy7oUkyZ3zS4fPZOVYa8gH/pciBkCw150K0= @@ -31,8 +37,11 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= @@ -44,33 +53,49 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY= +github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.94 h1:1ZoksIKPyaSt64AVOyaQvhDOgVC3MfZsWM6mZXRUGtM= +github.com/minio/minio-go/v7 v7.0.94/go.mod h1:71t2CqDt3ThzESgZUlU1rBN54mksGGlkLcFgguDnnAc= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= +github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas= golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/models/image.go b/models/image.go index 5feed5b..3eeadb1 100644 --- a/models/image.go +++ b/models/image.go @@ -57,13 +57,12 @@ 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 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"` + Rating config.Rating `gorm:"not null;default:'safe';size:15" 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"` @@ -83,7 +82,6 @@ type Image struct { 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) diff --git a/utils/format/files.go b/utils/format/files.go index 82f1546..dcdcaf7 100644 --- a/utils/format/files.go +++ b/utils/format/files.go @@ -51,21 +51,19 @@ func DecodeImage(imgData []byte) (image.Image, string, error) { return img, formatName, err } -func GetImageFileSize(img image.Image) int64 { +func GetImageSizeAndData(img image.Image) (int64, []byte, error) { var buf bytes.Buffer switch img.(type) { case *image.NRGBA, *image.RGBA, *image.YCbCr: - // Use PNG encoding for lossless compression err := png.Encode(&buf, img) if err != nil { - return 0 + return 0, nil, err } default: - // Fallback to JPEG encoding for other formats err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}) if err != nil { - return 0 + return 0, nil, err } } - return int64(buf.Len()) + return int64(buf.Len()), buf.Bytes(), nil } diff --git a/utils/minio/minio.go b/utils/minio/minio.go index c5576b1..30be90e 100644 --- a/utils/minio/minio.go +++ b/utils/minio/minio.go @@ -1 +1,48 @@ package minio + +import ( + "bytes" + "context" + "fmt" + "imageboard/config" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +func UploadImage(imageData []byte, sizeType config.ImageSizeType, fileName string, contentType string) error { + ctx := context.Background() + + minioClient, err := minio.New(config.S3.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(config.S3.AccessKey, config.S3.SecretAccessKey, ""), + Secure: config.S3.UseSSL, + }) + if err != nil { + return fmt.Errorf("failed to initialize MinIO client: %v", err) + } + + bucketExists, err := minioClient.BucketExists(ctx, config.S3.BucketName) + if err != nil { + return fmt.Errorf("failed to check bucket existence: %v", err) + } + + if !bucketExists { + err = minioClient.MakeBucket(ctx, config.S3.BucketName, minio.MakeBucketOptions{ + Region: config.S3.Region, + }) + if err != nil { + return fmt.Errorf("failed to create bucket: %v", err) + } + } + + objectPath := fmt.Sprintf("%s/%s", sizeType, fileName) + + _, err = minioClient.PutObject(ctx, config.S3.BucketName, objectPath, bytes.NewReader(imageData), int64(len(imageData)), minio.PutObjectOptions{ + ContentType: contentType, + }) + if err != nil { + return fmt.Errorf("failed to upload image: %v", err) + } + + return nil +} diff --git a/utils/transformers/image.go b/utils/transformers/image.go index e392f7f..369b7c6 100644 --- a/utils/transformers/image.go +++ b/utils/transformers/image.go @@ -3,20 +3,21 @@ package transformers import ( "image" "imageboard/config" - "imageboard/models" "imageboard/utils/format" "imageboard/utils/validators" "strings" + + "github.com/nfnt/resize" ) -func TransformImageToVariant(img image.Image, variant config.ImageSizeType) (models.ImageSize, image.Image, error) { +func TransformImageToVariant(img image.Image, variant config.ImageSizeType) (int, int, int64, []byte, error) { variantSizeMap := map[config.ImageSizeType]int{ config.ImageSizeTypeIcon: 64, config.ImageSizeTypeThumbnail: 256, config.ImageSizeTypeSmall: 512, config.ImageSizeTypeMedium: 1024, config.ImageSizeTypeLarge: 2048, - config.ImageSizeTypeOriginal: 0, // Original size, no resizing + config.ImageSizeTypeOriginal: 0, } maxWidth := variantSizeMap[variant] @@ -24,14 +25,12 @@ func TransformImageToVariant(img image.Image, variant config.ImageSizeType) (mod img = ResizeImage(img, maxWidth) } - fileSize := format.GetImageFileSize(img) + fileSize, imageData, err := format.GetImageSizeAndData(img) + if err != nil { + return 0, 0, 0, nil, err + } - return models.ImageSize{ - SizeType: variant, - Width: img.Bounds().Dx(), - Height: img.Bounds().Dy(), - FileSize: fileSize, - }, img, nil + return img.Bounds().Dx(), img.Bounds().Dy(), fileSize, imageData, nil } func ResizeImage(img image.Image, maxWidth int) image.Image { @@ -40,19 +39,9 @@ func ResizeImage(img image.Image, maxWidth int) image.Image { } ratio := float64(maxWidth) / float64(img.Bounds().Dx()) - newWidth := int(float64(img.Bounds().Dx()) * ratio) - newHeight := int(float64(img.Bounds().Dy()) * ratio) - newImg := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) - for y := 0; y < newHeight; y++ { - for x := 0; x < newWidth; x++ { - srcX := int(float64(x) / ratio) - srcY := int(float64(y) / ratio) - if srcX < img.Bounds().Dx() && srcY < img.Bounds().Dy() { - newImg.Set(x, y, img.At(srcX, srcY)) - } - } - } - return newImg + newHeight := uint(float64(img.Bounds().Dy()) * ratio) + + return resize.Resize(uint(maxWidth), newHeight, img, resize.Lanczos3) } func CreateUniqueFileName(sourceURLOrOriginalName, imageFormat string) string { @@ -64,11 +53,11 @@ func CreateUniqueFileName(sourceURLOrOriginalName, imageFormat string) string { currentTime := format.GetCurrentTimeAsTimestamp() fileNameWithoutExtension := format.RemoveExtension(fileName) - fileName = GenerateTokenFromString(fileNameWithoutExtension + "_" + format.Int64ToString(currentTime)) + fileName = GenerateTokenFromString(fileNameWithoutExtension + "_" + format.Int64ToString(currentTime) + "_" + GenerateUUID()) if len(fileName) > 32 { mid := len(fileName) / 2 - fileName = fileName[mid-16 : mid+16] + fileName = fileName[:mid-16] + format.Int64ToString(currentTime) + fileName[mid+16:] } return fileName + "." + imageFormat } @@ -87,3 +76,18 @@ func ConvertStringRatingToType(rating string) (config.Rating, error) { return config.RatingSafe, nil } } + +func ConvertStringToContentType(contentType string) (config.ImageContentType, error) { + switch contentType { + case "image/jpeg": + return config.ImageContentTypeJPEG, nil + case "image/png": + return config.ImageContentTypePNG, nil + case "image/gif": + return config.ImageContentTypeGIF, nil + case "image/webp": + return config.ImageContentTypeWebP, nil + default: + return config.ImageContentTypeJPEG, nil + } +} diff --git a/utils/transformers/tokens.go b/utils/transformers/tokens.go index f2f2e0b..b8452b1 100644 --- a/utils/transformers/tokens.go +++ b/utils/transformers/tokens.go @@ -2,8 +2,11 @@ package transformers import ( "crypto" + "crypto/md5" "crypto/rand" "encoding/hex" + + "github.com/google/uuid" ) func GenerateRandomToken() (string, error) { @@ -14,8 +17,18 @@ func GenerateRandomToken() (string, error) { return hex.EncodeToString(bytes), nil } +func GenerateUUID() string { + return uuid.New().String() +} + func GenerateTokenFromString(input string) string { hasher := crypto.SHA256.New() hasher.Write([]byte(input)) return hex.EncodeToString(hasher.Sum(nil)) } + +func GenerateMD5Hash(data []byte) string { + hasher := md5.New() + hasher.Write(data) + return hex.EncodeToString(hasher.Sum(nil)) +} -- cgit v1.2.3