diff --git a/modules/backend/handlers/common.go b/modules/backend/handlers/common.go index 7f2807f..b75fb66 100644 --- a/modules/backend/handlers/common.go +++ b/modules/backend/handlers/common.go @@ -18,16 +18,16 @@ import ( // } type Server struct { - db *sqlc.Queries - // publisher *rmq.Publisher - RPCclient *rmq.RPCClient + db *sqlc.Queries + ImageServerURL string + RPCclient *rmq.RPCClient } -func NewServer(db *sqlc.Queries, rpcclient *rmq.RPCClient) *Server { +func NewServer(db *sqlc.Queries, ImageServerURL string, rpcclient *rmq.RPCClient) *Server { return &Server{ - db: db, - // publisher: publisher, - RPCclient: rpcclient, + db: db, + ImageServerURL: ImageServerURL, + RPCclient: rpcclient, } } diff --git a/modules/backend/handlers/images.go b/modules/backend/handlers/images.go index c1e3d4b..c3d3a7f 100644 --- a/modules/backend/handlers/images.go +++ b/modules/backend/handlers/images.go @@ -3,32 +3,35 @@ package handlers import ( "bytes" "context" + "encoding/json" + "errors" "fmt" "image" "image/jpeg" "image/png" "io" + "mime/multipart" "net/http" oapi "nyanimedb/api" - "os" - "path/filepath" + sqlc "nyanimedb/sql" + "strings" "github.com/disintegration/imaging" + "github.com/jackc/pgx/v5/pgconn" log "github.com/sirupsen/logrus" "golang.org/x/image/webp" ) // PostMediaUpload implements oapi.StrictServerInterface. func (s *Server) PostMediaUpload(ctx context.Context, request oapi.PostMediaUploadRequestObject) (oapi.PostMediaUploadResponseObject, error) { - // Получаем multipart body + // 1. Получаем multipart body mp := request.MultipartBody if mp == nil { log.Errorf("PostMedia without body") return oapi.PostMediaUpload400JSONResponse("Multipart body is required"), nil } - // Парсим первую часть (предполагаем, что файл в поле "file") part, err := mp.NextPart() if err != nil { log.Errorf("PostMedia without file") @@ -36,8 +39,6 @@ func (s *Server) PostMediaUpload(ctx context.Context, request oapi.PostMediaUplo } defer part.Close() - // Читаем ВЕСЬ файл в память (для небольших изображений — нормально) - // Если файлы могут быть большими — используйте лимитированный буфер (см. ниже) data, err := io.ReadAll(part) if err != nil { log.Errorf("PostMedia cannot read file") @@ -45,97 +46,142 @@ func (s *Server) PostMediaUpload(ctx context.Context, request oapi.PostMediaUplo } if len(data) == 0 { - log.Errorf("PostMedia empty file") return oapi.PostMediaUpload400JSONResponse("Empty file"), nil } - // Проверка MIME по первым 512 байтам + // 2. Проверка и декодирование (оставляем как было) mimeType := http.DetectContentType(data) - if mimeType != "image/jpeg" && mimeType != "image/png" && mimeType != "image/webp" { - log.Errorf("PostMedia bad type") - return oapi.PostMediaUpload400JSONResponse("Bad data type"), nil - } - - // Декодируем изображение из буфера var img image.Image switch mimeType { case "image/jpeg": - { - img, err = jpeg.Decode(bytes.NewReader(data)) - if err != nil { - log.Errorf("PostMedia cannot decode file: %v", err) - return oapi.PostMediaUpload500Response{}, nil - } - } + img, err = jpeg.Decode(bytes.NewReader(data)) case "image/png": - { - img, err = png.Decode(bytes.NewReader(data)) - if err != nil { - log.Errorf("PostMedia cannot decode file: %v", err) - return oapi.PostMediaUpload500Response{}, nil - } - } + img, err = png.Decode(bytes.NewReader(data)) case "image/webp": - { - img, err = webp.Decode(bytes.NewReader(data)) - if err != nil { - log.Errorf("PostMedia cannot decode file: %v", err) + img, err = webp.Decode(bytes.NewReader(data)) + default: + log.Errorf("PostMedia unsupported type: %s", mimeType) + return oapi.PostMediaUpload400JSONResponse("Unsupported image type"), nil + } + + if err != nil { + log.Errorf("PostMedia decode error: %v", err) + return oapi.PostMediaUpload500Response{}, nil + } + + // 3. Перекодируем в PNG перед отправкой + var buf bytes.Buffer + if err := imaging.Encode(&buf, img, imaging.PNG); err != nil { + log.Errorf("PostMedia encode error: %v", err) + return oapi.PostMediaUpload500Response{}, nil + } + + // 4. Подготавливаем запрос к внешнему хранилищу (Image Server) + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // Создаем часть с файлом + partWriter, err := writer.CreateFormFile("file", "image.png") + if err != nil { + return oapi.PostMediaUpload500Response{}, nil + } + if _, err := io.Copy(partWriter, &buf); err != nil { + return oapi.PostMediaUpload500Response{}, nil + } + + // Добавляем subdir + _ = writer.WriteField("subdir", "posters") + writer.Close() + + // Формируем полный URL (s.AppConfig.ImageSocket теперь base_url, например "http://storage.local") + // Убедимся, что путь не дублирует слэши + uploadURL := strings.TrimSuffix(s.ImageServerURL, "/") + "/upload" + + // 5. Отправляем обычный HTTP POST + reqUpstream, err := http.NewRequestWithContext(ctx, "POST", uploadURL, body) + if err != nil { + return oapi.PostMediaUpload500Response{}, nil + } + reqUpstream.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := http.DefaultClient.Do(reqUpstream) + if err != nil { + log.Errorf("PostMedia upstream request failed: %v", err) + return oapi.PostMediaUpload500Response{}, nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Errorf("PostMedia storage returned status: %d", resp.StatusCode) + return oapi.PostMediaUpload500Response{}, nil + } + + // 6. Получаем путь из ответа хранилища + var storageResp struct { + Path string `json:"path"` + } + if err := json.NewDecoder(resp.Body).Decode(&storageResp); err != nil { + return oapi.PostMediaUpload500Response{}, nil + } + + // В storageResp.Path теперь лежит что-то вроде "posters/a1/b2/hash.png" + log.Infof("Successfully uploaded: %s", storageResp.Path) + + params := sqlc.CreateImageParams{ + StorageType: sqlc.StorageTypeTLocal, + ImagePath: storageResp.Path, + } + + // TODO: param for local/s3 case + _image, err := s.db.CreateImage(ctx, params) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + if pgErr.Code == pgErrDuplicateKey { //duplicate key value + log.Errorf("%v", err) + + _image, err = s.db.GetImageByPath(ctx, storageResp.Path) + if err != nil { + log.Errorf("%v", err) + return oapi.PostMediaUpload500Response{}, nil + } + image, err := mapImage(_image) + if err != nil { + log.Errorf("%v", err) + return oapi.PostMediaUpload500Response{}, nil + } + return oapi.PostMediaUpload200JSONResponse(*image), nil + } else { + log.Errorf("%v", err) return oapi.PostMediaUpload500Response{}, nil } + } else { + log.Errorf("%v", err) + return oapi.PostMediaUpload500Response{}, nil } } - var buf bytes.Buffer - err = imaging.Encode(&buf, img, imaging.PNG) + image, err := mapImage(_image) if err != nil { - log.Errorf("PostMedia failed to re-encode JPEG: %v", err) + log.Errorf("%v", err) return oapi.PostMediaUpload500Response{}, nil } - // TODO: to delete - filename := part.FileName() - if filename == "" { - filename = "upload_" + generateRandomHex(8) + ".jpg" - } else { - filename = sanitizeFilename(filename) - if !strings.HasSuffix(strings.ToLower(filename), ".png") { - filename += ".png" - } - } + return oapi.PostMediaUpload200JSONResponse(*image), nil +} - // TODO: пойти на хуй ( вызвать файловую помойку) - os.Mkdir("uploads", 0644) - err = os.WriteFile(filepath.Join("./uploads", filename), buf.Bytes(), 0644) +func mapImage(_image sqlc.Image) (*oapi.Image, error) { + + sType, err := sql2StorageType(&_image.StorageType) if err != nil { - log.Errorf("PostMedia failed to write: %v", err) - return oapi.PostMediaUpload500Response{}, nil + return nil, fmt.Errorf("mapImage: %v", err) } - return oapi.PostMediaUpload200JSONResponse{}, nil -} + image := oapi.Image{ + Id: &_image.ID, + ImagePath: &_image.ImagePath, + StorageType: sType, + } -// Вспомогательные функции — как раньше -func generateRandomHex(n int) string { - b := make([]byte, n) - for i := range b { - b[i] = byte('a' + (i % 16)) - } - return fmt.Sprintf("%x", b) -} - -func sanitizeFilename(name string) string { - var clean strings.Builder - for _, r := range name { - if (r >= 'a' && r <= 'z') || - (r >= 'A' && r <= 'Z') || - (r >= '0' && r <= '9') || - r == '.' || r == '_' || r == '-' { - clean.WriteRune(r) - } - } - s := clean.String() - if s == "" { - return "file" - } - return s + return &image, nil } diff --git a/modules/backend/main.go b/modules/backend/main.go index e7e6ec8..a67e533 100644 --- a/modules/backend/main.go +++ b/modules/backend/main.go @@ -10,6 +10,7 @@ import ( "time" oapi "nyanimedb/api" + handlers "nyanimedb/modules/backend/handlers" middleware "nyanimedb/modules/backend/middlewares" "nyanimedb/modules/backend/rmq" @@ -61,7 +62,7 @@ func main() { rpcClient := rmq.NewRPCClient(rmqConn, 30*time.Second) - server := handlers.NewServer(queries, rpcClient) + server := handlers.NewServer(queries, AppConfig.ImageServerURL, rpcClient) r.Use(cors.New(cors.Config{ AllowOrigins: []string{AppConfig.ServiceAddress}, diff --git a/modules/backend/queries.sql b/modules/backend/queries.sql index 7117456..99b59be 100644 --- a/modules/backend/queries.sql +++ b/modules/backend/queries.sql @@ -3,6 +3,11 @@ SELECT id, storage_type, image_path FROM images WHERE id = sqlc.arg('illust_id')::bigint; +-- name: GetImageByPath :one +SELECT id, storage_type, image_path +FROM images +WHERE image_path = sqlc.arg('illust_path')::text; + -- name: CreateImage :one INSERT INTO images (storage_type, image_path) VALUES ($1, $2) diff --git a/modules/backend/types.go b/modules/backend/types.go index ceaec4e..36c5380 100644 --- a/modules/backend/types.go +++ b/modules/backend/types.go @@ -8,4 +8,5 @@ type Config struct { LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` RmqURL string `toml:"RabbitMQUrl" env:"RABBITMQ_URL"` AuthEnabled string `toml:"AuthEnabled" env:"AUTH_ENABLED"` + ImageServerURL string `toml:"ImageServerURL" env:"IMAGES_BASE_URL"` } diff --git a/sql/queries.sql.go b/sql/queries.sql.go index 0c863e8..de8e12d 100644 --- a/sql/queries.sql.go +++ b/sql/queries.sql.go @@ -102,6 +102,19 @@ func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, erro return i, err } +const getImageByPath = `-- name: GetImageByPath :one +SELECT id, storage_type, image_path +FROM images +WHERE image_path = $1::text +` + +func (q *Queries) GetImageByPath(ctx context.Context, illustPath string) (Image, error) { + row := q.db.QueryRow(ctx, getImageByPath, illustPath) + var i Image + err := row.Scan(&i.ID, &i.StorageType, &i.ImagePath) + return i, err +} + const getReviewByID = `-- name: GetReviewByID :one SELECT id, data, rating, user_id, title_id, created_at