Compare commits

...
Sign in to create a new pull request.

4 commits

Author SHA1 Message Date
4fe077d229 feat: now PostImage return Image struct even on duplicate data
All checks were successful
Build (backend build only) / build (push) Successful in 3m18s
Build and Deploy Go App / build (push) Successful in 8m32s
Build and Deploy Go App / deploy (push) Successful in 27s
2025-12-20 01:16:26 +03:00
c58b578023 fix
All checks were successful
Build (backend build only) / build (push) Successful in 3m28s
2025-12-20 00:17:54 +03:00
53e270015c Merge branch 'dev' of ssh://meowgit.nekoea.red:22222/nihonium/nyanimedb into dev
All checks were successful
Build (backend build only) / build (push) Successful in 3m32s
2025-12-19 23:49:57 +03:00
858818c17e feat: UploadImage is written 2025-12-19 23:48:47 +03:00
6 changed files with 150 additions and 84 deletions

View file

@ -18,16 +18,16 @@ import (
// } // }
type Server struct { type Server struct {
db *sqlc.Queries db *sqlc.Queries
// publisher *rmq.Publisher ImageServerURL string
RPCclient *rmq.RPCClient 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{ return &Server{
db: db, db: db,
// publisher: publisher, ImageServerURL: ImageServerURL,
RPCclient: rpcclient, RPCclient: rpcclient,
} }
} }

View file

@ -3,32 +3,35 @@ package handlers
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"errors"
"fmt" "fmt"
"image" "image"
"image/jpeg" "image/jpeg"
"image/png" "image/png"
"io" "io"
"mime/multipart"
"net/http" "net/http"
oapi "nyanimedb/api" oapi "nyanimedb/api"
"os" sqlc "nyanimedb/sql"
"path/filepath"
"strings" "strings"
"github.com/disintegration/imaging" "github.com/disintegration/imaging"
"github.com/jackc/pgx/v5/pgconn"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"golang.org/x/image/webp" "golang.org/x/image/webp"
) )
// PostMediaUpload implements oapi.StrictServerInterface. // PostMediaUpload implements oapi.StrictServerInterface.
func (s *Server) PostMediaUpload(ctx context.Context, request oapi.PostMediaUploadRequestObject) (oapi.PostMediaUploadResponseObject, error) { func (s *Server) PostMediaUpload(ctx context.Context, request oapi.PostMediaUploadRequestObject) (oapi.PostMediaUploadResponseObject, error) {
// Получаем multipart body // 1. Получаем multipart body
mp := request.MultipartBody mp := request.MultipartBody
if mp == nil { if mp == nil {
log.Errorf("PostMedia without body") log.Errorf("PostMedia without body")
return oapi.PostMediaUpload400JSONResponse("Multipart body is required"), nil return oapi.PostMediaUpload400JSONResponse("Multipart body is required"), nil
} }
// Парсим первую часть (предполагаем, что файл в поле "file")
part, err := mp.NextPart() part, err := mp.NextPart()
if err != nil { if err != nil {
log.Errorf("PostMedia without file") log.Errorf("PostMedia without file")
@ -36,8 +39,6 @@ func (s *Server) PostMediaUpload(ctx context.Context, request oapi.PostMediaUplo
} }
defer part.Close() defer part.Close()
// Читаем ВЕСЬ файл в память (для небольших изображений — нормально)
// Если файлы могут быть большими — используйте лимитированный буфер (см. ниже)
data, err := io.ReadAll(part) data, err := io.ReadAll(part)
if err != nil { if err != nil {
log.Errorf("PostMedia cannot read file") log.Errorf("PostMedia cannot read file")
@ -45,97 +46,142 @@ func (s *Server) PostMediaUpload(ctx context.Context, request oapi.PostMediaUplo
} }
if len(data) == 0 { if len(data) == 0 {
log.Errorf("PostMedia empty file")
return oapi.PostMediaUpload400JSONResponse("Empty file"), nil return oapi.PostMediaUpload400JSONResponse("Empty file"), nil
} }
// Проверка MIME по первым 512 байтам // 2. Проверка и декодирование (оставляем как было)
mimeType := http.DetectContentType(data) 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 var img image.Image
switch mimeType { switch mimeType {
case "image/jpeg": case "image/jpeg":
{ img, err = jpeg.Decode(bytes.NewReader(data))
img, err = jpeg.Decode(bytes.NewReader(data))
if err != nil {
log.Errorf("PostMedia cannot decode file: %v", err)
return oapi.PostMediaUpload500Response{}, nil
}
}
case "image/png": case "image/png":
{ img, err = png.Decode(bytes.NewReader(data))
img, err = png.Decode(bytes.NewReader(data))
if err != nil {
log.Errorf("PostMedia cannot decode file: %v", err)
return oapi.PostMediaUpload500Response{}, nil
}
}
case "image/webp": case "image/webp":
{ img, err = webp.Decode(bytes.NewReader(data))
img, err = webp.Decode(bytes.NewReader(data)) default:
if err != nil { log.Errorf("PostMedia unsupported type: %s", mimeType)
log.Errorf("PostMedia cannot decode file: %v", err) 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 return oapi.PostMediaUpload500Response{}, nil
} }
} else {
log.Errorf("%v", err)
return oapi.PostMediaUpload500Response{}, nil
} }
} }
var buf bytes.Buffer image, err := mapImage(_image)
err = imaging.Encode(&buf, img, imaging.PNG)
if err != nil { if err != nil {
log.Errorf("PostMedia failed to re-encode JPEG: %v", err) log.Errorf("%v", err)
return oapi.PostMediaUpload500Response{}, nil return oapi.PostMediaUpload500Response{}, nil
} }
// TODO: to delete return oapi.PostMediaUpload200JSONResponse(*image), nil
filename := part.FileName() }
if filename == "" {
filename = "upload_" + generateRandomHex(8) + ".jpg"
} else {
filename = sanitizeFilename(filename)
if !strings.HasSuffix(strings.ToLower(filename), ".png") {
filename += ".png"
}
}
// TODO: пойти на хуй ( вызвать файловую помойку) func mapImage(_image sqlc.Image) (*oapi.Image, error) {
os.Mkdir("uploads", 0644)
err = os.WriteFile(filepath.Join("./uploads", filename), buf.Bytes(), 0644) sType, err := sql2StorageType(&_image.StorageType)
if err != nil { if err != nil {
log.Errorf("PostMedia failed to write: %v", err) return nil, fmt.Errorf("mapImage: %v", err)
return oapi.PostMediaUpload500Response{}, nil
} }
return oapi.PostMediaUpload200JSONResponse{}, nil image := oapi.Image{
} Id: &_image.ID,
ImagePath: &_image.ImagePath,
StorageType: sType,
}
// Вспомогательные функции — как раньше return &image, nil
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
} }

View file

@ -10,6 +10,7 @@ import (
"time" "time"
oapi "nyanimedb/api" oapi "nyanimedb/api"
handlers "nyanimedb/modules/backend/handlers" handlers "nyanimedb/modules/backend/handlers"
middleware "nyanimedb/modules/backend/middlewares" middleware "nyanimedb/modules/backend/middlewares"
"nyanimedb/modules/backend/rmq" "nyanimedb/modules/backend/rmq"
@ -61,7 +62,7 @@ func main() {
rpcClient := rmq.NewRPCClient(rmqConn, 30*time.Second) 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{ r.Use(cors.New(cors.Config{
AllowOrigins: []string{AppConfig.ServiceAddress}, AllowOrigins: []string{AppConfig.ServiceAddress},

View file

@ -3,6 +3,11 @@ SELECT id, storage_type, image_path
FROM images FROM images
WHERE id = sqlc.arg('illust_id')::bigint; 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 -- name: CreateImage :one
INSERT INTO images (storage_type, image_path) INSERT INTO images (storage_type, image_path)
VALUES ($1, $2) VALUES ($1, $2)

View file

@ -8,4 +8,5 @@ type Config struct {
LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"` LogLevel string `toml:"LogLevel" env:"LOG_LEVEL"`
RmqURL string `toml:"RabbitMQUrl" env:"RABBITMQ_URL"` RmqURL string `toml:"RabbitMQUrl" env:"RABBITMQ_URL"`
AuthEnabled string `toml:"AuthEnabled" env:"AUTH_ENABLED"` AuthEnabled string `toml:"AuthEnabled" env:"AUTH_ENABLED"`
ImageServerURL string `toml:"ImageServerURL" env:"IMAGES_BASE_URL"`
} }

View file

@ -102,6 +102,19 @@ func (q *Queries) GetImageByID(ctx context.Context, illustID int64) (Image, erro
return i, err 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 const getReviewByID = `-- name: GetReviewByID :one
SELECT id, data, rating, user_id, title_id, created_at SELECT id, data, rating, user_id, title_id, created_at