first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:40:14 +03:00
commit e04ba85564
129 changed files with 17541 additions and 0 deletions

View 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,
})
}

View 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)
}
}