first commit
This commit is contained in:
362
app/images/handlers/image.go
Normal file
362
app/images/handlers/image.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
imageModels "ginimageApi/app/images/models"
|
||||
"ginimageApi/configs"
|
||||
imageProcessor "ginimageApi/pkg/images"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ProcessImageResponse struct {
|
||||
Message string `json:"message"`
|
||||
FileName string `json:"file_name"`
|
||||
PublicPath string `json:"public_path"`
|
||||
URL string `json:"url"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int64 `json:"size"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Quality int `json:"quality"`
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
type ImageRecordResponse struct {
|
||||
ID uint `json:"id"`
|
||||
FileName string `json:"file_name"`
|
||||
PublicPath string `json:"public_path"`
|
||||
URL string `json:"url"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int64 `json:"size"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Quality int `json:"quality"`
|
||||
Format string `json:"format"`
|
||||
Mode string `json:"mode"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type ListImagesResponse struct {
|
||||
Count int `json:"count"`
|
||||
Items []ImageRecordResponse `json:"items"`
|
||||
}
|
||||
|
||||
type ImageErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func parseIntForm(c *gin.Context, key string, defaultValue int) (int, error) {
|
||||
raw := strings.TrimSpace(c.PostForm(key))
|
||||
if raw == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s sayi olmali", key)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func parseBoolForm(c *gin.Context, key string, defaultValue bool) bool {
|
||||
raw := strings.TrimSpace(strings.ToLower(c.PostForm(key)))
|
||||
if raw == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return raw == "1" || raw == "true" || raw == "yes" || raw == "on"
|
||||
}
|
||||
|
||||
func mimeFromFormat(format string) string {
|
||||
switch format {
|
||||
case "avif":
|
||||
return "image/avif"
|
||||
case "webp":
|
||||
return "image/webp"
|
||||
case "png":
|
||||
return "image/png"
|
||||
default:
|
||||
return "image/jpeg"
|
||||
}
|
||||
}
|
||||
|
||||
func outputDir() string {
|
||||
d := strings.TrimSpace(os.Getenv("IMAGE_OUTPUT_DIR"))
|
||||
if d == "" {
|
||||
return "uploads/processed"
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func randomSuffix() string {
|
||||
b := make([]byte, 4)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "rand"
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func getUserID(c *gin.Context) (uint, bool) {
|
||||
v, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case uint:
|
||||
return t, true
|
||||
case int:
|
||||
if t < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return uint(t), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func requestBaseURL(c *gin.Context) string {
|
||||
if base := strings.TrimSpace(os.Getenv("PUBLIC_BASE_URL")); base != "" {
|
||||
return strings.TrimRight(base, "/")
|
||||
}
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if proto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")); proto != "" {
|
||||
scheme = proto
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, c.Request.Host)
|
||||
}
|
||||
|
||||
func ensureDBAndUser(c *gin.Context) (uint, bool) {
|
||||
if configs.DB == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db baglantisi yok"})
|
||||
return 0, false
|
||||
}
|
||||
userID, ok := getUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "kullanici bulunamadi"})
|
||||
return 0, false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
func toImageRecordResponse(c *gin.Context, img imageModels.Image) ImageRecordResponse {
|
||||
return ImageRecordResponse{
|
||||
ID: img.ID,
|
||||
FileName: img.Filename,
|
||||
PublicPath: img.PublicPath,
|
||||
URL: requestBaseURL(c) + img.PublicPath,
|
||||
MimeType: img.MimeType,
|
||||
Size: img.Size,
|
||||
Width: img.Width,
|
||||
Height: img.Height,
|
||||
Quality: img.Quality,
|
||||
Format: img.Format,
|
||||
Mode: img.Mode,
|
||||
CreatedAt: img.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ListImages godoc
|
||||
// @Summary Giris yapan kullanicinin kayitli resimlerini listeler
|
||||
// @Tags images
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} ListImagesResponse
|
||||
// @Failure 401 {object} ImageErrorResponse
|
||||
// @Failure 500 {object} ImageErrorResponse
|
||||
// @Router /api/v1/images [get]
|
||||
func ListImages(c *gin.Context) {
|
||||
userID, ok := ensureDBAndUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var images []imageModels.Image
|
||||
if err := configs.DB.Where("user_id = ?", userID).Order("id desc").Find(&images).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "resimler listelenemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]ImageRecordResponse, 0, len(images))
|
||||
for _, item := range images {
|
||||
items = append(items, toImageRecordResponse(c, item))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ListImagesResponse{Count: len(items), Items: items})
|
||||
}
|
||||
|
||||
// GetImage godoc
|
||||
// @Summary Giris yapan kullanicinin tekil resim kaydini getirir
|
||||
// @Tags images
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "Image ID"
|
||||
// @Success 200 {object} ImageRecordResponse
|
||||
// @Failure 400 {object} ImageErrorResponse
|
||||
// @Failure 401 {object} ImageErrorResponse
|
||||
// @Failure 404 {object} ImageErrorResponse
|
||||
// @Failure 500 {object} ImageErrorResponse
|
||||
// @Router /api/v1/images/{id} [get]
|
||||
func GetImage(c *gin.Context) {
|
||||
userID, ok := ensureDBAndUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "gecersiz image id"})
|
||||
return
|
||||
}
|
||||
|
||||
var image imageModels.Image
|
||||
err = configs.DB.Where("id = ? AND user_id = ?", uint(id), userID).First(&image).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "resim bulunamadi"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "resim getirilemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, toImageRecordResponse(c, image))
|
||||
}
|
||||
|
||||
// Process godoc
|
||||
// @Summary Resmi en, boy, kalite ve formata gore isler
|
||||
// @Tags images
|
||||
// @Accept mpfd
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param file formData file true "Yuklenecek resim"
|
||||
// @Param width formData int false "Hedef genislik (default: orijinal)"
|
||||
// @Param height formData int false "Hedef yukseklik (default: orijinal)"
|
||||
// @Param quality formData int false "Kalite 1-100 (default: 90)"
|
||||
// @Param format formData string false "avif|webp|png|jpg|jpeg (default: avif)"
|
||||
// @Param cover formData boolean false "true ise cover crop uygular"
|
||||
// @Success 200 {object} ProcessImageResponse
|
||||
// @Failure 400 {object} ImageErrorResponse
|
||||
// @Failure 401 {object} ImageErrorResponse
|
||||
// @Failure 500 {object} ImageErrorResponse
|
||||
// @Router /api/v1/images/process [post]
|
||||
func Process(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file alani zorunlu"})
|
||||
return
|
||||
}
|
||||
|
||||
width, err := parseIntForm(c, "width", 0)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
height, err := parseIntForm(c, "height", 0)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
quality, err := parseIntForm(c, "quality", imageProcessor.DefaultQuality)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
opts := imageProcessor.ProcessOptions{
|
||||
Width: width,
|
||||
Height: height,
|
||||
Quality: quality,
|
||||
Format: c.PostForm("format"),
|
||||
Cover: parseBoolForm(c, "cover", false),
|
||||
}
|
||||
normalized, err := imageProcessor.NormalizeOptions(opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := ensureDBAndUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dosya acilamadi"})
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
buffer, err := io.ReadAll(src)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dosya okunamadi"})
|
||||
return
|
||||
}
|
||||
|
||||
processed, err := imageProcessor.ProcessImage(buffer, normalized)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outputDir(), 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "output klasoru olusturulamadi"})
|
||||
return
|
||||
}
|
||||
|
||||
baseName := strings.TrimSuffix(file.Filename, filepath.Ext(file.Filename))
|
||||
outName := fmt.Sprintf("%s_%d_%s.%s", baseName, time.Now().Unix(), randomSuffix(), normalized.Format)
|
||||
absPath := filepath.Join(outputDir(), outName)
|
||||
if err := os.WriteFile(absPath, processed, 0o644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "islenmis dosya kaydedilemedi"})
|
||||
return
|
||||
}
|
||||
|
||||
processedSize := int64(len(processed))
|
||||
imgSize, _ := imageProcessor.GetSize(processed)
|
||||
publicPath := "/uploads/processed/" + outName
|
||||
url := requestBaseURL(c) + publicPath
|
||||
|
||||
record := imageModels.Image{
|
||||
UserID: userID,
|
||||
Filename: outName,
|
||||
PublicPath: publicPath,
|
||||
MimeType: mimeFromFormat(normalized.Format),
|
||||
Size: processedSize,
|
||||
Width: imgSize.Width,
|
||||
Height: imgSize.Height,
|
||||
Quality: normalized.Quality,
|
||||
Format: normalized.Format,
|
||||
Mode: map[bool]string{true: "cover", false: "fit"}[normalized.Cover],
|
||||
}
|
||||
if err := configs.DB.Create(&record).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db image kaydi olusturulamadi"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ProcessImageResponse{
|
||||
Message: "resim isleme tamamlandi",
|
||||
FileName: outName,
|
||||
PublicPath: publicPath,
|
||||
URL: url,
|
||||
MimeType: record.MimeType,
|
||||
Size: record.Size,
|
||||
Width: record.Width,
|
||||
Height: record.Height,
|
||||
Quality: record.Quality,
|
||||
Format: record.Format,
|
||||
})
|
||||
}
|
||||
144
app/images/handlers/image_test.go
Normal file
144
app/images/handlers/image_test.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
imageModels "ginimageApi/app/images/models"
|
||||
"ginimageApi/configs"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupImageHandlersTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
prev := configs.DB
|
||||
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite open failed: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&imageModels.Image{}); err != nil {
|
||||
t.Fatalf("migrate failed: %v", err)
|
||||
}
|
||||
configs.DB = db
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
configs.DB = prev
|
||||
})
|
||||
}
|
||||
|
||||
func withUser(userID uint) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set("user_id", userID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessRequiresFile(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/images/process", Process)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/images/process", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessRejectsInvalidWidth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/images/process", Process)
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
filePart, err := writer.CreateFormFile("file", "dummy.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create form file: %v", err)
|
||||
}
|
||||
_, _ = filePart.Write([]byte("not-a-real-image"))
|
||||
_ = writer.WriteField("width", "abc")
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("failed to close writer: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/images/process", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListImagesReturnsOnlyCurrentUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupImageHandlersTestDB(t)
|
||||
|
||||
seed := []imageModels.Image{
|
||||
{UserID: 1, Filename: "a.avif", PublicPath: "/uploads/processed/a.avif", MimeType: "image/avif", Size: 10, Format: "avif", Quality: 90},
|
||||
{UserID: 1, Filename: "b.avif", PublicPath: "/uploads/processed/b.avif", MimeType: "image/avif", Size: 11, Format: "avif", Quality: 90},
|
||||
{UserID: 2, Filename: "c.avif", PublicPath: "/uploads/processed/c.avif", MimeType: "image/avif", Size: 12, Format: "avif", Quality: 90},
|
||||
}
|
||||
if err := configs.DB.Create(&seed).Error; err != nil {
|
||||
t.Fatalf("seed failed: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/images", withUser(1), ListImages)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/images", nil)
|
||||
req.Host = "localhost:8080"
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Count int `json:"count"`
|
||||
Items []struct {
|
||||
ID uint `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("json parse failed: %v", err)
|
||||
}
|
||||
if resp.Count != 2 || len(resp.Items) != 2 {
|
||||
t.Fatalf("expected 2 images for current user, got count=%d len=%d", resp.Count, len(resp.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetImageRejectsOtherUsersImage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
setupImageHandlersTestDB(t)
|
||||
|
||||
img := imageModels.Image{UserID: 2, Filename: "x.avif", PublicPath: "/uploads/processed/x.avif", MimeType: "image/avif", Size: 5, Format: "avif", Quality: 90}
|
||||
if err := configs.DB.Create(&img).Error; err != nil {
|
||||
t.Fatalf("seed failed: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/images/:id", withUser(1), GetImage)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/images/1", nil))
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
20
app/images/models/images.go
Normal file
20
app/images/models/images.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Filename string `gorm:"not null" json:"filename"`
|
||||
PublicPath string `gorm:"not null" json:"public_path"`
|
||||
MimeType string `gorm:"not null" json:"mime_type"`
|
||||
Size int64 `json:"size"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Quality int `json:"quality"`
|
||||
Format string `json:"format"`
|
||||
Mode string `json:"mode"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
Reference in New Issue
Block a user