diff --git a/backend/internal/controllers/upload.go b/backend/internal/controllers/upload.go
new file mode 100644
index 0000000..feb5caf
--- /dev/null
+++ b/backend/internal/controllers/upload.go
@@ -0,0 +1,108 @@
+// Copyright (c) 2025 Nikolai Papin
+//
+// This file is part of Easywish
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
+// the GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package controllers
+
+import (
+ "easywish/internal/middleware"
+ "easywish/internal/models"
+ "easywish/internal/services"
+ "easywish/internal/utils/enums"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+ "golang.org/x/time/rate"
+)
+
+type UploadController struct {
+ log *zap.Logger
+ us services.UploadService
+}
+
+func NewUploadController(_log *zap.Logger, _us services.UploadService) Controller {
+ ctrl := UploadController{log: _log, us: _us}
+
+ return &controllerImpl{
+ Path: "/upload",
+ Middleware: []gin.HandlerFunc{
+ middleware.RateLimitMiddleware(rate.Every(2*time.Second), 1),
+ },
+ Methods: []ControllerMethod{
+ {
+ HttpMethod: GET,
+ Path: "/avatar",
+ Authorization: enums.UserRole,
+ Middleware: []gin.HandlerFunc{},
+ Function: ctrl.getAvatarUploadUrl,
+ },
+ {
+ HttpMethod: GET,
+ Path: "/image",
+ Authorization: enums.UserRole,
+ Middleware: []gin.HandlerFunc{},
+ Function: ctrl.getImageUploadUrl,
+ },
+ },
+ }
+}
+
+// XXX: untested
+// @Summary Get presigned URL for avatar upload
+// @Tags Upload
+// @Accept json
+// @Produce json
+// @Security JWT
+// @Success 200 {object} models.PresignedUploadResponse "Presigned URL and form data"
+// @Failure 500 "Internal server error"
+// @Router /upload/avatar [get]
+func (ctrl *UploadController) getAvatarUploadUrl(c *gin.Context) {
+ url, formData, err := ctrl.us.GetAvatarUrl()
+ if err != nil {
+ ctrl.log.Error("Failed to generate avatar upload URL", zap.Error(err))
+ c.Status(http.StatusInternalServerError)
+ return
+ }
+
+ c.JSON(http.StatusOK, models.PresignedUploadResponse{
+ Url: *url,
+ Fields: *formData,
+ })
+}
+
+// XXX: untested
+// @Summary Get presigned URL for image upload
+// @Tags Upload
+// @Accept json
+// @Produce json
+// @Security JWT
+// @Success 200 {object} models.PresignedUploadResponse "Presigned URL and form data"
+// @Failure 500 "Internal server error"
+// @Router /upload/image [get]
+func (ctrl *UploadController) getImageUploadUrl(c *gin.Context) {
+ url, formData, err := ctrl.us.GetImageUrl()
+ if err != nil {
+ c.Status(http.StatusInternalServerError)
+ return
+ }
+
+ c.JSON(http.StatusOK, models.PresignedUploadResponse{
+ Url: *url,
+ Fields: *formData,
+ })
+}
diff --git a/backend/internal/middleware/ratelimit.go b/backend/internal/middleware/ratelimit.go
new file mode 100644
index 0000000..1e32def
--- /dev/null
+++ b/backend/internal/middleware/ratelimit.go
@@ -0,0 +1,37 @@
+// Copyright (c) 2025 Nikolai Papin
+//
+// This file is part of Easywish
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
+// the GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package middleware
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "golang.org/x/time/rate"
+)
+
+func RateLimitMiddleware(r rate.Limit, b int) gin.HandlerFunc {
+ limiter := rate.NewLimiter(r, b)
+
+ return func(c *gin.Context) {
+ if !limiter.Allow() {
+ c.AbortWithStatus(http.StatusTooManyRequests)
+ return
+ }
+ c.Next()
+ }
+}
diff --git a/backend/internal/models/upload.go b/backend/internal/models/upload.go
new file mode 100644
index 0000000..606f46d
--- /dev/null
+++ b/backend/internal/models/upload.go
@@ -0,0 +1,24 @@
+// Copyright (c) 2025 Nikolai Papin
+//
+// This file is part of Easywish
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
+// the GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package models
+
+type PresignedUploadResponse struct {
+ Url string `json:"url"`
+ Fields map[string]string `json:"fields"`
+}
+