aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2025-07-17 14:47:43 +0530
committerBobby <[email protected]>2025-07-17 14:47:43 +0530
commitf826397be8178dc3be812ac95c5d9219a7924c32 (patch)
treefe9682f1c3873691dc9113a6c67e571e0d799424
parente7cb677c0558b042944a900394c1c196c5d1b898 (diff)
downloadimageboard-f826397be8178dc3be812ac95c5d9219a7924c32.tar.xz
imageboard-f826397be8178dc3be812ac95c5d9219a7924c32.zip
image upload feature
-rw-r--r--controllers/posts.go492
-rw-r--r--database/database.go2
-rw-r--r--database/images.go46
-rw-r--r--go.mod20
-rw-r--r--go.sum37
-rw-r--r--models/image.go4
-rw-r--r--utils/format/files.go10
-rw-r--r--utils/minio/minio.go47
-rw-r--r--utils/transformers/image.go54
-rw-r--r--utils/transformers/tokens.go13
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))
+}